9

Please note that even though the example in this question is encoded in Javascript, the underlying concepts are common in Haskell and I while I prefer to express myself in Javascript I also appreciate answers in Haskell.

In Javascript I use CPS to handle asynchronous computations according to monadic principles. For the sake of simplicity, however, I will use the normal continuation monad for this question.

As soon as my continuation compositions grow, I keep finding myself in a situation where I need access to intermediate results of these compositions. Since Javascript is imperative it is easy to store such results in variables and access them later. But since we're talking about continuations accessing intermediate results means calling functions and accessing them several times means a lot of re-evaluation.

This seems to be well suited for memoization. But how can I memoize a function's return value if that very function doesn't return anything but merely calls its continuation (and btw. as I mentioned before I use asynchronous functions that also don't return anything in the current cycle of Javascript's event loop).

It seems as if I have to extract the right continuation. Maybe this is possible with delimited continuations through shift/reset, but I don't know how to apply these combinators. This issue is probably not that hard to solve and I'm just confused by the magical land of continuation passing style...so please be indulgent with me.

Here is a simplified example of Cont without memoization in Javascript:

const taggedLog = tag => s =>
  (console.log(tag, s), s);

const id = x => x;

const Cont = k => ({
  runCont: k,
  [Symbol.toStringTag]: "Cont"
});

const contAp = tf => tx =>
  Cont(k => tf.runCont(f => tx.runCont(x => k(f(x)))));

const contLiftA2 = f => tx => ty =>
  contAp(contMap(f) (tx)) (ty);

const contOf = x => Cont(k => k(x));

const contMap = f => tx =>
  Cont(k => tx.runCont(x => k(f(x))));
                                  
const contReset = tx => // delimited continuations
  contOf(tx.runCont(id));

const contShift = f => // delimited continuations
  Cont(k => f(k).runCont(id));

const inc = contMap(x => taggedLog("eval inc") (x + 1));
const inc2 = inc(contOf(2));
const inc3 = inc(contOf(3));
const add = contLiftA2(x => y => taggedLog("eval add") (x + y));
const mul = contLiftA2(x => y => taggedLog("eval mul") (x * y));

const intermediateResult = add(inc2) (inc3);

mul(intermediateResult) (intermediateResult).runCont(id);

/*
  should only log four lines:
  eval inc 3
  eval inc 4
  eval add 7
  eval mul 49
*/
  • Do futures/promises not answer your question? I find your question unclear because the example does not leverage continuations, so one answer would be "don't use continuations". I also feel like there's a distinction to be made between "sharing" and "memoization", that again would require more involved examples to see. – Li-yao Xia Aug 01 '19 at 20:34
  • "*since we're talking about continuations accessing intermediate results means calling functions*" - no, why would it? – Bergi Aug 01 '19 at 20:41
  • @Li-yao Xia distinguishing memoization from sharing is probably good advice. I will look into this. Thanks. –  Aug 02 '19 at 05:38
  • @Li-yaoXia Btw, I gave a hint in my question how I leverage continuations in real life: By applying them to asynchronous computations in a language with an event loop based concurrency model. But I figured to skip this detail in order to simplify the example. –  Aug 02 '19 at 07:07

2 Answers2

9

Your problems seems to be that your Cont has no monad implementation yet. With that, it's totally simple to access previous results - they're just in scope (as constants) of nested continuation callbacks:

const contChain = tx => f =>
  Cont(k => tx.runCont(r => f(r).runCont(k)));

contChain( add(inc2) (inc3), intermediateResult => {
  const intermediateCont = contOf(intermediateResult);
  return mul(intermediateCont) (intermediateCont);
}).runCont(id);

(Of course it's a little weird that all your functions are already lifted and take Cont values as arguments - they shouldn't do that and simply be functions that return Cont values)


Your code in Haskell:

import Control.Monad.Cont
import Control.Applicative

let inc = liftA (+1)
let inc2 = inc $ return 2
let inc3 = inc $ return 3
let add = liftA2 (+)
let mul = liftA2 (*)

(`runCont` id) $ add inc2 inc3 >>= \intermediateResult ->
  let intermediateCont = return intermediateResult
  in mul intermediateCont intermediateCont
-- 49

{- or with do notation: -}

(`runCont` id) $ do
  intermediateResult <- add inc2 inc3
  let intermediateCont = return intermediateResult
  mul intermediateCont intermediateCont
-- 49

(I haven't used monad transformers to make a taggedLog side effect)

Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • Yep, this works for small chains. But as they grow it is getting a pain to thread intermediate results through to their targets. This is the Reader monad, right? Still i had to fall back to monads whenever i need to use intermediates. Monads are cool but i can not compose them except with transformers, so i try to use applicative whenever possible. –  Aug 02 '19 at 05:35
  • No, I don''t think this is the `Reader` monad, which makes the values to be read available implicitly. It's just monadic usage of`Cont`. – Bergi Aug 02 '19 at 07:50
  • I want to avoid threading intermediate results through long monadic chains. What I want is to break the chain and to share intermediate results in applicative/monadic computations without reevaluating them each time. I appreciate your effort but this answer is not particularly helpful to me, sorry. –  Aug 02 '19 at 09:08
  • 1
    @bob Then you will probably need to compose the Cont monad with a State monad to share results. Or break purity. – Bergi Aug 02 '19 at 09:34
  • I'm on to something - see my own answer. If this approach works for a proper, real world example I can bypass the monad constraint. –  Aug 02 '19 at 11:04
  • Yes, `Cont` doing the caching itself (basically becoming a `Lazy`) is what I meant by breaking purity. Should be fine if it solves your realworld problem :-) – Bergi Aug 02 '19 at 13:10
  • It does break purity in a technical sense. But if the involved functions are pure then the mutation is only local, because replacing a CPS computation with its result is just what referential transparency guarantees us. So in a way this modification enforces purity and any violation will eventually blow up your program. –  Aug 02 '19 at 13:18
  • Imposing purity on the consumer is not a big deal though, because if you need IO or random bytes or date related stuff just put the computation inside a continuation based `Task` or thunk based `Lazy`. Peace of cake. –  Aug 02 '19 at 13:20
1

It seems that I can't avoid getting impure to obtain the desired behavior. The impurity is only local though, because I just replace the continuation chain with its result value. I can do this without changing the behavior of my program, because this is exactly what referential transparency guarantees us.

Here is the transformation of the Cont constructor:

const Cont = k => ({
  runCont: k,
  [Symbol.toStringTag]: "Cont"
});

// becomes

const Cont = k => thisify(o => { // A
    o.runCont = (res, rej) => k(x => { // B
      o.runCont = l => l(x); // C
      return res(x); // D
    }, rej); // E

    o[Symbol.toStringTag] = "Cont";
    return o;
  });

thisify in line A merely mimics this context, so that the Object to be constructed is aware of itself.

Line B is the decisive change: Instead of just passing res to the continuation k I construct another lambda that stores the result x wrapped in a continuation under the runTask property of the current Task object (C), before it calls res with x (D).

In case of an error rej is just applied to x, as usual (E).

Here is the runnning example from above, now working as expected:

const taggedLog = pre => s =>
  (console.log(pre, s), s);

const id = x => x;

const thisify = f => f({}); // mimics this context

const Cont = k => thisify(o => {
    o.runCont = (res, rej) => k(x => {
      o.runCont = l => l(x);
      return res(x);
    }, rej);
    
    o[Symbol.toStringTag] = "Cont";
    return o;
  });

const contAp = tf => tx =>
  Cont(k => tf.runCont(f => tx.runCont(x => k(f(x)))));

const contLiftA2 = f => tx => ty =>
  contAp(contMap(f) (tx)) (ty);

const contOf = x => Cont(k => k(x));

const contMap = f => tx =>
  Cont(k => tx.runCont(x => k(f(x))));
                                  
const inc = contMap(x => taggedLog("eval inc") (x + 1));
const inc2 = inc(contOf(2));
const inc3 = inc(contOf(3));
const add = contLiftA2(x => y => taggedLog("eval add") (x + y));
const mul = contLiftA2(x => y => taggedLog("eval mul") (x * y));

const intermediateResult = add(inc2) (inc3);

mul(intermediateResult) (intermediateResult).runCont(id);

/* should merely log
eval inc 3
eval inc 4
eval add 7
eval add 49
*/
  • @Bergi The impure approach is quite tricky. I had to shift the destructive update into the `runCont` handler so that the approach also works with continuations in an asynchronous context. This has the additional benefit that the impurity is deferred until the `Cont`s are actually run. –  Aug 06 '19 at 12:16