如何为延续 monad 实现堆栈安全的 chainRec 运算符?

How to implement a stack-safe chainRec operator for the continuation monad?

我目前正在试验延续 monad。 Cont 实际上在 Javascript 中很有用,因为它从回调模式中抽象出来。

当我们处理单子递归时,总是有堆栈溢出的风险,因为递归调用不在尾部位置:

const chain = g => f => k =>
  g(x => f(x) (k));

const of = x => k =>
  k(x);
  
const id = x =>
  x;

const inc = x =>
  x + 1;

const repeat = n => f => x => 
  n === 0
    ? of(x)
    : chain(of(f(x))) (repeat(n - 1) (f));

console.log(
  repeat(1e6) (inc) (0) (id) // stack overflow
);

然而,即使我们能够将某些情况转换为尾递归,我们仍然注定失败,因为 Javascript 没有 TCO。因此,我们必须在某个时候回退到循环。

puresrcipt 有一个带有 tailRecM 运算符的 MonadRec 类型类,可以为某些单子启用尾递归单子计算。所以我主要根据fantasy land spec:

尝试在Javascript中实现chainRec

const chain = g => f => k => g(x => f(x) (k));
const of = x => k => k(x);
const id = x => x;

const Loop = x =>
  ({value: x, done: false});

const Done = x =>
  ({value: x, done: true});

const chainRec = f => x => {
  let step = f(Loop, Done, x);

  while (!step.done) {
    step = f(Loop, Done, step.value);
  }

  return of(step.value);
};

const repeat_ = n => f => x => 
  chainRec((Loop, Done, [n, x]) => n === 0 ? Done(x) : Loop([n - 1, f(x)])) ([n, x]);

console.log(
  repeat_(1e6) (n => n + 1) (0) (id) // 1000000
);

这行得通,但它看起来很像作弊,因为它似乎绕过了单子链,因此绕过了 Cont 的上下文。在这种情况下,上下文只是 "the rest of the computation",即。反向函数组合,结果返回期望值。但它适用于任何 monad 吗?

为了弄清楚我的意思,请看一下 中的以下代码片段:

const Bounce = (f,x) => ({ isBounce: true, f, x })

const Cont = f => ({
  _runCont: f,
  chain: g =>
    Cont(k =>
      Bounce(f, x =>
        Bounce(g(x)._runCont, k)))
})

// ...

const repeat = n => f => x => {
  const aux = (n,x) =>
    n === 0 ? Cont.of(x) : Cont.of(f(x)).chain(x => aux(n - 1, x))
  return runCont(aux(n,x), x => x)
}

这里chain以某种方式融入了递归算法,也就是会出现单子效应。不幸的是,我无法破译此运算符或将其与堆栈不安全版本协调(Bounce(g(x)._runCont, k) 似乎是 f(x) (k) 部分)。

最后,我的问题是我是否搞砸了 chainRec 的实施或误解了 FL 规范或两者或两者或 none?


[编辑]

给出的两个答案都非常有帮助,从不同的角度看问题,值得采纳。因为我只能接受一个 - 嘿 Whosebug,世界没那么简单!!! - 我不会接受任何。

祝好,

我想这可能就是您要找的,

const chainRec = f => x =>
  f ( chainRec (f)
    , of
    , x
    )

实施 repeat 就像你拥有的一样——有两个例外(感谢@Bergi 了解这个细节)。 1、loopdone链接函数,所以chainRec回调必须return延续。并且 2,我们必须 标记 带有 run 的函数,这样 cont 知道我们什么时候可以安全地折叠未决延续的堆栈——更改 bold

const repeat_ = n => f => x =>
  chainRec
    ((loop, done, [n, x]) =>
       n === 0
         ? <b>of (x) (done)</b>                // cont chain done
         : <b>of ([ n - 1, f (x) ]) (loop)</b> // cont chain loop
    ([ n, x ])

const repeat = n => f => x =>
  repeat_ (n) (f) (x) (<b>run (</b>identity<b>)</b>)

但是,如果您像我们这里那样使用 chainRec,当然没有理由定义中间值 repeat_。我们可以直接定义repeat

const repeat = n => f => x =>
  chainRec
    ((loop, done, [n, x]) =>
       n === 0
         ? of (x) (done)
         : of ([ n - 1, f (x) ]) (loop)
    ([ n, x ])
    (run (identity))

现在要让它工作,你只需要一个 stack-safe continuation monad – cont (f) 构造一个 continuation,等待动作 g。如果 g 被标记为 run,那么是时候在 trampoline 上反弹了。否则构造一个新的延续,为 fg

添加顺序 call
// not actually stack-safe; we fix this below
const cont = f => g =>
  is (run, g)
    ? trampoline (f (g))
    : cont (k =>
        call (f, x =>
          call (g (x), k)))

const of = x =>
  cont (k => k (x))

在我们继续之前,我们将验证一切正常

const TAG =
  Symbol ()

const tag = (t, x) =>
  Object.assign (x, { [TAG]: t })
  
const is = (t, x) =>
  x && x [TAG] === t

// ----------------------------------------

const cont = f => g =>
  is (run, g)
    ? trampoline (f (g))
    : cont (k =>
        call (f, x =>
          call (g (x), k)))
  
const of = x =>
  cont (k => k (x))

const chainRec = f => x =>
  f ( chainRec (f)
    , of
    , x
    )
  
const run = x =>
  tag (run, x)
  
const call = (f, x) =>
  tag (call, { f, x })  

const trampoline = t =>
{
  let acc = t
  while (is (call, acc))
    acc = acc.f (acc.x)
  return acc
}

// ----------------------------------------

const identity = x =>
  x
  
const inc = x =>
  x + 1

const repeat = n => f => x =>
  chainRec
    ((loop, done, [n, x]) =>
       n === 0
         ? of (x) (done)
         : of ([ n - 1, f (x) ]) (loop))
    ([ n, x ])
    (run (identity))
      
console.log (repeat (1e3) (inc) (0))
// 1000

console.log (repeat (1e6) (inc) (0))
// Error: Uncaught RangeError: Maximum call stack size exceeded

bug在哪里?

所提供的两种实现方式存在重大差异。具体来说,g(x)._runCont 扁平化 结构。这个任务使用 Cont 的 JS 对象编码是微不足道的,因为我们可以通过简单地阅读 g(x)

._runCont 属性 来展平
const Cont = f =>
  ({ _runCont: f
   , chain: g =>
       Cont (k =>
         Bounce (f, x =>
          // g(x) returns a Cont, flatten it
          Bounce (g(x)._runCont, k))) 
   })

在我们的新编码中,我们使用 函数 来表示 cont,除非我们提供另一个特殊信号(就像我们对 run),一旦它被部分应用,就无法在 cont 之外访问 f – 查看下面的 g (x)

const cont = f => g =>
  is (run, g)
    ? trampoline (f (g))
    : cont (k =>
        call (f, x =>
          // g (x) returns partially-applied `cont`, how to flatten?
          call (g (x), k))) 

上面,g (x)会return一个partially-appliedcont,(即cont (something)),但这意味着整个cont函数可以无限嵌套。而不是 cont-wrapped something,我们 想要 something.

我花在这个答案上的至少 50% 的时间都在想出各种方法来展平 partially-applied cont。这个解决方案不是特别优雅,但它确实完成了工作并准确地突出了需要发生的事情。我真的很想知道您可能会发现哪些其他编码 – bold

中的变化
<b>const FLATTEN =
  Symbol ()</b>

const cont = f => g =>
  <b>g === FLATTEN
    ? f</b>
    : is (run, g)
      ? trampoline (f (g))
      : cont (k =>
          call (f, x =>
            call (g (x) <b>(FLATTEN)</b>, k)))

全系统在线,舰长

使用 cont 扁平化补丁,其他一切正常。现在看 chainRec 进行一百万次迭代......

const TAG =
  Symbol ()

const tag = (t, x) =>
  Object.assign (x, { [TAG]: t })
  
const is = (t, x) =>
  x && x [TAG] === t

// ----------------------------------------

const FLATTEN =
  Symbol ()

const cont = f => g =>
  g === FLATTEN
    ? f
    : is (run, g)
      ? trampoline (f (g))
      : cont (k =>
          call (f, x =>
            call (g (x) (FLATTEN), k)))
  
const of = x =>
  cont (k => k (x))

const chainRec = f => x =>
  f ( chainRec (f)
    , of
    , x
    )
  
const run = x =>
  tag (run, x)
  
const call = (f, x) =>
  tag (call, { f, x })  

const trampoline = t =>
{
  let acc = t
  while (is (call, acc))
    acc = acc.f (acc.x)
  return acc
}

// ----------------------------------------

const identity = x =>
  x
  
const inc = x =>
  x + 1

const repeat = n => f => x =>
  chainRec
    ((loop, done, [n, x]) =>
       n === 0
         ? of (x) (done)
         : of ([ n - 1, f (x) ]) (loop))
    ([ n, x ])
    (run (identity))
      
console.log (repeat (1e6) (inc) (0))
// 1000000

cont的进化

当我们在上面的代码中引入 cont 时,并不清楚这种编码是如何导出的。我希望能阐明这一点。我们从希望如何定义cont

开始
const cont = f => g =>
  cont (comp (g,f))

const comp = (f, g) =>
  x => f (g (x))

在这种形式下,cont 将无休止地推迟评估。 唯一我们可以做的事情是应用g总是创建另一个cont并推迟我们的行动。我们添加了一个逃生口 run,它向 cont 发出我们不想再推迟的信号。

const cont = f => g =>
  <b>is (run, g)
    ? f (g)</b>
    <b>:</b> cont (comp (g,f))

const is = ...

const run = ...

const square = x =>
  of (x * x)

of (4) (square) (square) (run (console.log))
// 256

square (4) (square) (run (console.log))
// 256

以上,我们可以开始看到cont是如何表达出优美纯净的程序了。然而在没有tail-call消除的环境中,这仍然允许程序构建超过计算器堆栈限制的延迟函数序列。 comp 直接链接功能,所以这是不可能的。相反,我们将使用我们自己制作的 call 机制对函数进行排序。当程序发出 run 信号时,我们使用 trampoline.

折叠调用堆栈

下面,我们得到了应用扁平修复之前的表单

const cont = f => g =>
  is (run, g)
    ? <b>trampoline (</b>f (g)<b>)</b>
    <del>: cont (comp (g,f))</del>
    <b>: cont (k =>
        call (f, x =>
          call (g (x), k)))</b>

const trampoline = ...

const call = ...

一厢情愿

我们在上面使用的另一种技术是我的最爱之一。当我写 is (run, g) 时,我不知道我将如何立即表示 isrun,但我可以稍后弄清楚。我对 trampolinecall.

使用相同的一厢情愿的想法

我指出这一点是因为这意味着我可以将所有复杂性排除在 cont 之外,而只关注其基本结构。我最终得到了一组函数,这些函数给了我这种 "tagging" 行为

// tag contract
// is (t, tag (t, value)) == true

const TAG =
  Symbol ()

const tag = (t, x) =>
  Object.assign (x, { [TAG]: t })

const is = (t, x) =>
  x && x [TAG] === t

const run = x =>
  tag (run, x)

const call = (f, x) =>
  tag (call, { f, x })

如意算盘就是写出你想要的程序,让你的愿望成真。一旦您实现了所有愿望,您的程序就会神奇地运行!

Did I mess up the implementation of chainRec, or misunderstood the FantasyLand spec, or both or none of it?

可能两者都有,或者至少是第一个。请注意 the type should be

chainRec :: ChainRec m => ((a -> c, b -> c, a) -> m c, a) -> m b

其中 mContc 是 Done/Loop 对 ab 的包装:

chainRec :: ((a -> DL a b, b -> DL a b, a) -> Cont (DL a b), a) -> Cont b

但是您的 chainRecrepeat 实现根本不使用连缀!

如果我们只实现那种类型,而不要求它需要常量堆栈space,它看起来像

const chainRec = f => x => k =>
  f(Loop, Done, x)(step =>
    step.done
      ? k(step.value) // of(step.value)(k)
      : chainRec(f)(step.value)(k)
  );

或者如果我们甚至放弃惰性要求(类似于将 chaing => f => k => g(x => f(x)(k)) 转换为 g => f => g(f)(即 g => f => k => g(x => f(x))(k))),它看起来像

const chainRec = f => x =>
  f(Loop, Done, x)(step =>
    step.done
      ? of(step.value)
      : chainRec(f)(step.value)
  );

甚至下降 Done/Loop

const join = chain(id);
const chainRec = f => x => join(f(chainRec(f), of, x));

(我希望我不会在这方面走得太远,但它完美地呈现了 ChainRec 背后的想法)

使用延迟延续和 non-recursive 蹦床,我们会写

const chainRec = f => x => k => {
  let step = Loop(x);
  do {
    step = f(Loop, Done, step.value)(id);
//                                  ^^^^ unwrap Cont
  } while (!step.done)
  return k(step.value); // of(step.value)(k)
};

循环语法(用 f 调用初始化 stepdo/while 而不是 do)并不重要,你的也很好,但是重要的部分是 f(Loop, Done, v) returns 的延续。

我将把 repeat 的实现作为练习留给 reader :D
(提示:如果你有 repeated 函数 f 已经使用了 continuations,它可能会变得更有用也更容易正确)