user5536315
user5536315

Reputation:

How to thread arguments implicitly throughout a composition using the Reader Monad in an untyped lang?

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

Answers (1)

user5536315
user5536315

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

Related Questions