Reputation:
I know that the bare reader monad merely consists of two functions:
const chain = g => f => x => f(g(x)) (x);
const of = x => _ => x;
But I don't have any intuition of how it works or how it is applied. Knowing that is is used to thread arguments implicitly throughout a composition doesn't help much.
Upvotes: 3
Views: 54
Reputation:
The reader monad is hard to grasp because the feature it utilizes is rather ordinary (function application) and its application is kind of non-inuitive. How can f => g => x => f(g(x)) (x)
be useful, when both arguments are initially the same? Let's start with a simple example:
const inc = x => x + 1; const sqr = x => x * x; const add = x => y => x + y;
Depending on the position of add
these functions cannot be composed out of the box due to deviating arity. In a more generalized sense you can state that add
needs an extra argument and inc
/sqr
need to be aware of this circumstance.
The Reader Monad can be used to gain more flexibility in such scenarios. In an untyped setting the Reader value is just a function stored in a plain old JS object:
const Reader = f => ({
[Symbol.toStringTag]: "Reader",
run: e => f(e) // eta expanded for clarity
});
const incR = x => Reader(e => x + 1);
const sqrR = x => Reader(e => x * x);
const addR = x => Reader(e => x + e);
The functions from the initial example are now adjusted to the new Reader "type". e
is the decesive argument, called the environment. It is the implicit, extra argument handeled by the Reader Monad. e
can be a scalar value or a composite one to encode several extra arguments. As you can see e
is only used by addR
and ignored by the rest.
How can these functions be composed? Obviously, normal function composition doesn't work anymore. We need a construct that encodes how composition works with the Reader type. This is exactly what the monad structure gives us:
const Reader = f => ({
[Symbol.toStringTag]: "Reader",
run: e => f(e)
});
const id = x => x;
Reader.chain = mg => fm => Reader(e => fm(mg.run(e)).run(e));
Reader.of = x => Reader(_ => x);
Reader.ask = Reader(id);
const r = Reader.chain(incR(2)) (x => // Reader {run: f}
Reader.chain(sqrR(x)) (y =>
Reader.chain(addR(y)) (z =>
Reader(e => [e, z]))));
I use Reader.chain
for composing and feed the value 2
into the composition. The result r
of the computation is Reader {run: f}
. This gives us the clue that the composition isn't evaluated yet. Something is missing. Right, the environment argument. Let's pass it:
r.run(5) // [5, 14]
The composition yields the original environment argument e
and the calculated result. Here is the untangled calculation:
2 + 1 = 3
3 * 3 = 9
9 + 5 = 14
// ^env
Reader.chain
builds up a nested function call stack, a description of a computation that is only evaluated when the environment argument is passed.
What if we want sqrK
to be based on e
as well? Just Reader.ask
the environment:
const r2 = Reader.chain(incR(2)) (x =>
Reader.chain(Reader.ask) (e2 =>
// ^^^^^^^^^^
Reader.chain(sqrR(e2 % 2 === 1 ? 1 : x)) (y =>
Reader.chain(addR(y)) (z =>
Reader(e => [e, z])))));
r2.run(5); // [5, 6]
r2.run(4); // [4, 13]
All that's necessary is an additional Reader.chain(Reader.ask)
call. e2
provides the environment to the subsequent continuation.
That's the gist of it. It's a lot of monadic boilerplate in return for implicitly threaded arguments. I'd say it is still useful if you need to pass a configuration around and are already composing using another monad. Monads of different type don't compose out of the box but you can use a monad transformer stack.
Here is a runnable example of the given example including an infix
operator for flat composition syntax:
const r_ = infix(
incR,
kompR,
sqrR,
kompR,
addR) (2);
Reader.chain(r_) (z => Reader(e => [e, z])).run(5);
Alternatively, you can use generator syntactic sugar to get a more imperative coding experience: https://stackoverflow.com/a/65060136/5536315.
const Reader = f => ({
[Symbol.toStringTag]: "Reader",
run: e => f(e)
});
const id = x => x;
const log = x => console.log(x);
Reader.map = f => tg => Reader(e => f(tg.run(e)));
Reader.ap = tf => tg => Reader(e => tf.run(e) (tg.run(e)))
Reader.of = x => Reader(_ => x);
Reader.chain = mg => fm => Reader(e => fm(mg.run(e)).run(e));
Reader.ask = Reader(id);
const incR = x => Reader(e => x + 1);
const sqrR = x => Reader(e => x * x);
const addR = x => Reader(e => x + e);
const komp = ({chain}) => fm => gm => x => chain(fm(x)) (gm);
const kompR = komp({chain: Reader.chain});
const makeInfix = nestFirst => (...args) => {
if (args.length === 0) throw new TypeError("no argument found");
let i = 1, x = args[0];
while (i < args.length) {
if (i === 1) x = args[i++] (x) (args[i++]);
else if (nestFirst) x = args[i++] (x) (args[i++]);
else x = args[i++] (args[i++]) (x);
}
return x;
};
const infix = makeInfix(true);
const r = Reader.chain(incR(2)) (x =>
Reader.chain(sqrR(x)) (y =>
Reader.chain(addR(y)) (z =>
Reader(e => [e, z]))));
r.run(5); // [5, 14]
const r2 = Reader.chain(incR(2)) (x =>
Reader.chain(Reader.ask) (e2 =>
Reader.chain(sqrR(e2 % 2 === 1 ? 1 : x)) (y =>
Reader.chain(addR(y)) (z =>
Reader(e => [e, z])))));
r2.run(5); // [5, 6]
r2.run(4); // [4, 13]
const r_ = infix(
incR,
kompR,
sqrR,
kompR,
addR) (2);
log(Reader.chain(r_) (z => Reader(e => [e, z])).run(5)); // [5, 14]
const r2_ = infix(
incR,
kompR,
x => Reader(e => e % 2 === 1 ? 1 : x * x),
kompR,
addR) (2);
log(Reader.chain(r2_) (z => Reader(e => [e, z])).run(5)); // [5, 6]
log(Reader.chain(r2_) (z => Reader(e => [e, z])).run(4)); // [4, 13]
Upvotes: 1