如何在没有尾递归优化的情况下,用函数式编程替换while循环?

64

我正在尝试在JavaScript中使用更具功能性的风格,因此我用诸如map和reduce之类的实用函数替换了for循环。然而,由于JavaScript通常不支持尾调用优化,我还没有找到while循环的功能性替代方法。(据我所知,ES6可以防止尾调用溢出堆栈,但不会优化它们的性能。)

下面是我尝试过的方法,但简要来说:

如果我没有尾调用优化,那么有什么功能性的方式来实现while循环?

我尝试创建了一个“while”实用函数:

function while(func, test, data) {
  const newData = func(data);
  if(test(newData)) {
    return newData;
  } else {
    return while(func, test, newData);
  }
}

因为没有可用的尾调用优化,我可以将其重写为:

function while(func, test, data) {
  let newData = *copy the data somehow*
  while(test(newData)) {
    newData = func(newData);
  }
  return newData;
}

然而,现在我感觉自己的代码变得更加复杂/令其他使用者困惑了,因为我必须携带一个自定义的实用函数。我看到的唯一实际优势是它强制我使循环纯净;但似乎更直接的方法是只使用常规 while 循环,并确保我保持一切纯净。
我还尝试着想出一种创建生成器函数的方式,模拟递归/循环的效果,然后使用像 find 或 reduce 这样的实用函数对其进行迭代。但是,我还没有找到一种可读性好的方法。
最后,用实用函数替换 for 循环更能清楚地表达我想要完成的任务(例如对每个元素进行操作,检查每个元素是否通过测试等)。然而,对我来说,while 循环已经表达了我想要完成的任务(例如,迭代直到找到一个质数,迭代直到答案被足够优化等)。
所以,在所有这些之后,我的总问题是:如果我需要 while 循环,我正在以功能风格编程,并且我无法访问尾调用优化,那么最佳策略是什么?

1
如果您需要性能而TCO不可用,则无法避免循环。如果您不需要性能,且想以函数式编程风格编写代码,只需使用递归即可。 - Bergi
1
在我看来,ES6确实需要尾调用优化;也就是说,当一个尾调用创建了一个新的栈帧时,ES6要求使用的内存仅增加新栈帧和旧栈帧之间的差异,因此通过递归调用同一函数创建的栈帧不应该导致内存增加。话虽如此,并非每个实现都支持ES6(而且ES6仅在严格模式下支持TCO),因此这里仍然存在一个有效的问题。 - apsillers
3
据我所知,消除递归的传统方法(也许是唯一的方法?)就是使用trampolining(http://raganwald.com/2013/03/28/trampolines-in-javascript.html),即通过从函数中返回来弹出调用栈,然后再使用先前的返回值重新调用该函数。Trampolining需要一个循环,而这也是你在此处所做的事情。 - apsillers
“但不优化它们的性能”是什么意思? - Bergi
5个回答

130

JavaScript示例

以下是使用JavaScript的示例。目前,大多数浏览器不支持尾调用优化,因此以下代码段将失败。

const repeat = n => f => x =>
  n === 0 ? x : repeat (n - 1) (f) (f(x))
  
console.log(repeat(1e3) (x => x + 1) (0)) // 1000
console.log(repeat(1e5) (x => x + 1) (0)) // Error: Uncaught RangeError: Maximum call stack size exceeded


弹跳(Trampolines)

我们可以通过改变 repeat 函数的写法来解决这个限制,但只需稍作修改。我们不再直接返回一个值或者立即递归调用,而是返回两种 trampoline 类型之一:BounceDone。然后我们将使用 trampoline 函数来处理循环。

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

const Done = x => ({ isBounce: false, x })

const trampoline = ({ isBounce, f, x }) => {
  while (isBounce)
    ({ isBounce, f, x } = f(x))
  return x
}

// our revised repeat function, now stack-safe
const repeat = n => f => x =>
  n === 0 ? Done(x) : Bounce(repeat (n - 1) (f), f(x))


// apply trampoline to the result of an ordinary call repeat
let result = trampoline(repeat(1e6) (x => x + 1) (0))

// no more stack overflow
console.log(result) // 1000000

柯里化也会稍微降低速度,但我们可以使用一个辅助函数来解决递归问题。这也很好,因为它隐藏了跳板实现细节,不需要调用者弹回返回值。与上面的repeat相比,这运行速度大约快两倍。

// aux helper hides trampoline implementation detail
// runs about 2x as fast
const repeat = n => f => x => {
  const aux = (n, x) =>
    n === 0 ? Done(x) : Bounce(x => aux (n - 1, x), f (x))
  return trampoline (aux (n, x))
}

Clojure风格的loop/recur

跳板很好,但必须担心在函数返回值上调用trampoline有些烦人。我们看到的替代方法是使用辅助帮助器,但这也有点烦人。我确定你们中的一些人对BounceDone包装器也不太感兴趣。

Clojure创建了一个专门的跳板接口,使用一对函数looprecur - 这个配对组合非常优雅地表达了程序。

哦,它也非常快。

const recur = (...values) =>
  ({ recur, values })
  
const loop = run =>
{ let r = run ()
  while (r && r.recur === recur)
    r = run (...r.values)
  return r
}

const repeat = n => f => x =>
  loop
    ( (m = n, r = x) =>
        m === 0
          ? r
          : recur (m - 1, f (r))
    )
  
console.time ('loop/recur')
console.log (repeat (1e6) (x => x + 1) (0)) // 1000000
console.timeEnd ('loop/recur')              // 24 ms

起初,这种风格可能会感到陌生,但随着时间的推移,我发现它在制作耐久程序时是最一致的。下面的注释有助于使您逐步适应富表达式语法 -

const repeat = n => f => x =>
  loop  // begin a loop with
    ( ( m = n   // local loop var m: counter, init with n
      , r = x   // local loop var r: result, init with x
      ) =>
        m === 0 // terminating condition
          ? r   // return result
          : recur    // otherwise recur with 
             ( m - 1 // next m value
             , f (r) // next r value
             )
    )

续延单子

这是我最喜欢的主题之一,因此我们将看看使用续延单子实现的情况。通过重用looprecur,我们实现了一个堆栈安全的cont,它可以使用chain序列化操作并使用runCont运行操作序列。对于repeat来说,这是毫无意义的(而且速度很慢),但在这个简单的例子中,看到cont的机制工作仍然很酷。

const identity = x =>
  x

const recur = (...values) =>
  ({ recur, values })
  
const loop = run =>
{ let r = run ()
  while (r && r.recur === recur)
    r = run (...r.values)
  return r
}

// cont : 'a -> 'a cont
const cont = x =>
  k => recur (k, x)

// chain : ('a -> 'b cont) -> 'a cont -> 'b cont
const chain = f => mx =>
  k => recur (mx, x => recur (f (x), k))

// runCont : ('a -> 'b) -> a cont -> 'b
const runCont = f => mx =>
  loop ((r = mx, k = f) => r (k))

const repeat = n => f => x =>
{ const aux = n => x =>
    n === 0 // terminating condition
      ? cont (x) // base case, continue with x
      : chain             // otherise
          (aux (n - 1))   // sequence next operation on
          (cont (f (x)))  // continuation of f(x)

  return runCont  // run continuation
    (identity)    // identity; pass-thru
    (aux (n) (x)) // the continuation returned by aux
}

console.time ('cont monad')
console.log (repeat (1e6) (x => x + 1) (0)) // 1000000
console.timeEnd ('cont monad')              // 451 ms


Y组合子

Y组合子是我灵感的来源;如果不把它与其他技术放在一起,这个答案就不完整了。然而,大多数Y组合子的实现都不是堆栈安全的,如果用户提供的函数递归次数太多,就会溢出。由于这个答案关注保持堆栈安全行为,所以当然我们会以安全的方式实现Y - 再次依靠我们可靠的跳板。

Y演示了扩展易于使用、堆栈安全、同步无限递归的能力,而不会使您的函数混乱不堪。

const bounce = f => (...xs) =>
  ({ isBounce: true, f, xs })

const trampoline = t => {
  while (t && t.isBounce)
    t = t.f(...t.xs)
  return t
}

// stack-safe Y combinator
const Y = f => {
  const safeY = f =>
    bounce((...xs) => f (safeY (f), ...xs))
  return (...xs) =>
    trampoline (safeY (f) (...xs))
}

// recur safely to your heart's content
const repeat = Y ((recur, n, f, x) =>
  n === 0
    ? x
    : recur (n - 1, f, f (x)))
  
console.log(repeat (1e5, x => x + 1, 0)) // 10000


使用while循环的实用性

但是说实话:当我们忽略了更明显的潜在解决方案时,这就是很多仪式。可以使用forwhile循环,但将其隐藏在函数接口后面。

就所有目的而言,这个repeat函数的工作方式与上面提供的函数完全相同 - 除了这个函数可能快一两亿倍(除了loop/recur解决方案)。它甚至可能更容易阅读。

当然,这个函数可能是一个牵强的例子 - 并非所有递归函数都可以如此轻松地转换为forwhile循环,但在这种情况下,最好就像这样做。当简单的循环不起作用时,请将跳板和连续性保留给重型升降机。

const repeat = n => f => x => {
  let m = n
  while (true) {
    if (m === 0)
      return x
    else
      (m = m - 1, x = f (x))
  }
}

const gadzillionTimes = repeat(1e8)

const add1 = x => x + 1

const result = gadzillionTimes (add1) (0)

console.log(result) // 100000000


setTimeout不是解决堆栈溢出问题的方法

好吧,它确实可以工作,但只是矛盾的。如果您的数据集很小,您不需要setTimeout,因为不会发生堆栈溢出。如果您的数据集很大并且使用setTimeout作为安全递归机制,不仅无法同步从函数返回值,而且速度非常慢,您甚至都不想使用函数

有些人发现了一个面试Q&A准备网站,鼓励这种可怕的策略

使用setTimeout重复的样子 - 请注意,它也以延续传递样式定义 - 即,我们必须使用回调(k)调用repeat才能获得最终值

// do NOT implement recursion using setTimeout
const repeat = n => f => x => k =>
  n === 0
    ? k (x)
    : setTimeout (x => repeat (n - 1) (f) (x) (k), 0, f (x))
    
// be patient, this one takes about 5 seconds, even for just 1000 recursions
repeat (1e3) (x => x + 1) (0) (console.log)

// comment the next line out for absolute madness
// 10,000 recursions will take ~1 MINUTE to complete
// paradoxically, direct recursion can compute this in a few milliseconds
// setTimeout is NOT a fix for the problem
// -----------------------------------------------------------------------------
// repeat (1e4) (x => x + 1) (0) (console.log)

我无法强调这有多糟糕。即使是1e5,运行时间也太长了,以至于我放弃尝试测量它。我不会在下面的基准测试中包含它,因为它太慢了,甚至不能被视为一种可行的方法。

承诺(Promises)

承诺具有链接计算和堆栈安全的能力。然而,使用 Promises 来实现一个堆栈安全的 repeat 意味着我们将不得不放弃同步返回值,就像我们使用 setTimeout 一样。我提供这个"解决方案"是因为它确实解决了问题,不像 setTimeout 那样,但与 trampoline 或 continuation monad 相比,它非常简单。尽管性能有些差,但远不及上面的 setTimeout 示例那么糟糕。

需要注意的是,这个解决方案中,Promise 的实现细节对调用者完全隐藏。一个单一的 continuation 作为第四个参数提供,并且在计算完成时被调用。

const repeat = n => f => x => k =>
  n === 0
    ? Promise.resolve(x).then(k)
    : Promise.resolve(f(x)).then(x => repeat (n - 1) (f) (x) (k))
    
// be patient ...
repeat (1e6) (x => x + 1) (0) (x => console.log('done', x))


基准测试

说真的,while 循环要快得多 - 像是快了将近100倍(比较最好和最差情况,但不包括异步回答:setTimeoutPromise)。

// sync
// -----------------------------------------------------------------------------
// repeat implemented with basic trampoline
console.time('A')
console.log(tramprepeat(1e6) (x => x + 1) (0))
console.timeEnd('A')
// 1000000
// A 114 ms

// repeat implemented with basic trampoline and aux helper
console.time('B')
console.log(auxrepeat(1e6) (x => x + 1) (0))
console.timeEnd('B')
// 1000000
// B 64 ms

// repeat implemented with cont monad
console.time('C')
console.log(contrepeat(1e6) (x => x + 1) (0))
console.timeEnd('C')
// 1000000
// C 33 ms

// repeat implemented with Y
console.time('Y')
console.log(yrepeat(1e6) (x => x + 1) (0))
console.timeEnd('Y')
// 1000000
// Y 544 ms

// repeat implemented with while loop
console.time('D')
console.log(whilerepeat(1e6) (x => x + 1) (0))
console.timeEnd('D')
// 1000000
// D 4 ms

// async
// -----------------------------------------------------------------------------

// repeat implemented with Promise
console.time('E')
promiserepeat(1e6) (x => x + 1) (0) (console.log)
console.timeEnd('E')
// 1000000
// E 2224 ms

// repeat implemented with setTimeout; FAILED
console.time('F')
timeoutrepeat(1e6) (x => x + 1) (0) (console.log)
console.timeEnd('F')
// ...
// too slow; didn't finish after 3 minutes

JavaScript的石器时代

以上技术是使用更新的ES6语法演示的,但您可以在最早版本的JavaScript中实现一个跳板,它只需要while和一等函数。

以下内容使用JavaScript的石器时代演示了无限递归是可能的,并且在不必牺牲同步返回值的情况下性能良好 - 在 6 秒内实现 100,000,000 次递归 - 与 只能在相同时间内 1,000 次递归的setTimeout相比,这是一个巨大的差异。

function trampoline (t) {
  while (t && t.isBounce)
    t = t.f (t.x);
  return t.x;
}

function bounce (f, x) {
  return { isBounce: true, f: f, x: x };
}

function done (x) {
  return { isBounce: false, x: x };
}

function repeat (n, f, x) {
  function aux (n, x) {
    if (n === 0)
      return done (x);
    else 
      return bounce (function (x) { return aux (n - 1, x); }, f (x));
  }
  return trampoline (aux (n, x));
}

console.time('JS1 100K');
console.log (repeat (1e5, function (x) { return x + 1 }, 0));
console.timeEnd('JS1 100K');
// 100000
// JS1 100K: 15ms

console.time('JS1 100M');
console.log (repeat (1e8, function (x) { return x + 1 }, 0));
console.timeEnd('JS1 100M');
// 100000000
// JS1 100K: 5999ms

使用史前JavaScript实现非阻塞无限递归 如果出于某种原因,您需要非阻塞(异步)无限递归,我们可以依靠setTimeout在计算开始时延迟一个单帧。该程序还使用史前javascript,在不阻塞的情况下计算了100,000,000次递归,但这次只用了不到8秒。
这说明,具有非阻塞要求并没有什么特别之处。一个while循环和一级函数仍然是实现堆栈安全递归而不牺牲性能的唯一基本要求。
在现代程序中,鉴于Promises,我们将把setTimeout调用替换为单个Promise。

function donek (k, x) {
  return { isBounce: false, k: k, x: x };
}

function bouncek (f, x) {
  return { isBounce: true, f: f, x: x };
}

function trampolinek (t) {
  // setTimeout is called ONCE at the start of the computation
  // NOT once per recursion
  return setTimeout(function () {
    while (t && t.isBounce) {
      t = t.f (t.x);
    }
    return t.k (t.x);
  }, 0);
}

// stack-safe infinite recursion, non-blocking, 100,000,000 recursions in under 8 seconds
// now repeatk expects a 4th-argument callback which is called with the asynchronously computed result
function repeatk (n, f, x, k) {
  function aux (n, x) {
    if (n === 0)
      return donek (k, x);
    else
      return bouncek (function (x) { return aux (n - 1, x); }, f (x));
  }
  return trampolinek (aux (n, x));
}

console.log('non-blocking line 1')
console.time('non-blocking JS1')
repeatk (1e8, function (x) { return x + 1; }, 0, function (result) {
  console.log('non-blocking line 3', result)
  console.timeEnd('non-blocking JS1')
})
console.log('non-blocking line 2')

// non-blocking line 1
// non-blocking line 2
// [ synchronous program stops here ]
// [ below this line, asynchronous program continues ]
// non-blocking line 3 100000000
// non-blocking JS1: 7762ms


8
哇靠!这个好全面啊,真不错。但我以为ES6现在支持尾调用优化了……你有没有更简单的例子来帮我们更容易理解一下?哈哈哈 - james emanon
1
@jamesemanon,花点时间看看蹦床代码片段-它是最直接的,并且具有出色的性能。 - Mulan
4
更新:我不得不承认loop/recur接口比传统的trampoline要好得多-它更适合表达函数式程序,并且仍然非常快。 - Mulan
1
我必须承认,loop/recur接口比传统的trampoline更好——它更适合表达函数式程序... 我不同意。trampoline接口更好。你可以在定义时而不是调用时用Bounce包装尾调用。因此,你可以使用Bounce/Return模式代替loop/recur模式。其他优点是你可以指定任意尾调用函数,并且你可以创建一个单子实例,因为Trampoline = Free Identity - Aadit M Shah
1
@Rewind "notation" 只是普通的JavaScriptBounceDone返回简单的对象,而trampoline在每一步评估对象并决定是否再次运行循环(bounce)或停止并返回一个值(done)。这个想法在这个问答中得到了扩展,我们为安全的堆栈设计了自己的复杂递归模式的求值器。我还推荐Aadit的答案作为更正式的解释。Aadit很聪明。像Aadit一样。 - Mulan
显示剩余11条评论

13

更好的loop/recur模式

我对被接受的答案中描述的loop/recur模式有两个不喜欢的地方。请注意,我实际上很喜欢loop/recur模式背后的思想。然而,我不喜欢它的实现方式。因此,让我们首先看一下我将如何实现它。

// Recur :: a -> Result a b
const Recur = (...args) => ({ recur: true, args });

// Return :: b -> Result a b
const Return = value => ({ recur: false, value });

// loop :: (a -> Result a b) -> a -> b
const loop = func => (...args) => {
    let result = func(...args);
    while (result.recur) result = func(...result.args);
    return result.value;
};

// repeat :: (Int, a -> a, a) -> a
const repeat = loop((n, f, x) => n === 0 ? Return(x) : Recur(n - 1, f, f(x)));

console.time("loop/recur/return");
console.log(repeat(1e6, x => x + 1, 0));
console.timeEnd("loop/recur/return");

将此与上述答案中描述的loop/recur模式进行比较。

// recur :: a -> Recur a
const recur = (...args) => ({ recur, args });

// loop :: (a? -> Recur a ∪ b) -> b
const loop = func => {
    let result = func();
    while (result && result.recur === recur) result = func(...result.args);
    return result;
};

// repeat :: (Int, a -> a, a) -> a
const repeat = (n, f, x) => loop((m = n, r = x) => m === 0 ? r : recur(m - 1, f(r)));

console.time("loop/recur");
console.log(repeat(1e6, x => x + 1, 0));
console.timeEnd("loop/recur");

如果你注意到了,第二个loop函数的类型签名使用了默认参数(即a?),以及未标记联合(即Recur a ∪ b)。这两个特性都与函数式编程范式不符。

默认参数的问题

上述答案中的loop/recur模式使用默认参数来提供函数的初始参数。我认为这是对默认参数的滥用。你可以使用我的版本的loop同样轻松地提供初始参数。

// repeat :: (Int, a -> a, a) -> a
const repeat = (n, f, x) => loop((n, x) => n === 0 ? Return(x) : Recur(n - 1, f(x)))(n, x);

// or more readable
const repeat = (n, f, x) => {
    const repeatF = loop((n, x) => n === 0 ? Return(x) : Recur(n - 1, f(x)));
    return repeatF(n, x);
};

此外,当所有参数经过传递时,它允许进行eta转换
// repeat :: (Int, a -> a, a) -> a
const repeat = (n, f, x) => loop((n, f, x) => n === 0 ? Return(x) : Recur(n - 1, f, f(x)))(n, f, x);

// can be η-converted to
const repeat = loop((n, f, x) => n === 0 ? Return(x) : Recur(n - 1, f, f(x)));

使用默认参数的loop版本不允许Eta转换。此外,它强制你重命名参数,因为在JavaScript中无法写(n = n, x = x) => ...

无标签联合存在的问题

未标记的联合类型很糟糕,因为它们会擦除重要信息,即数据来源的信息。例如,因为我的Result类型是有标记的,所以我可以区分Return(Recur(0))Recur(0)
另一方面,由于Recur a ∪ b的右侧变体未标记,如果b被专门化为Recur a,即类型被专门化为Recur a ∪ Recur a,那么就无法确定Recur a来自左侧还是右侧。
可能会有一个批评是b永远不会被专门化为Recur a,因此b是否带有标记并不重要。以下是对该批评的简单反例。

// recur :: a -> Recur a
const recur = (...args) => ({ recur, args });

// loop :: (a? -> Recur a ∪ b) -> b
const loop = func => {
    let result = func();
    while (result && result.recur === recur) result = func(...result.args);
    return result;
};

// repeat :: (Int, a -> a, a) -> a
const repeat = (n, f, x) => loop((m = n, r = x) => m === 0 ? r : recur(m - 1, f(r)));

// infinite loop
console.log(repeat(1, x => recur(1, x), "wow, such hack, much loop"));

// unreachable code
console.log("repeat wasn't hacked");

将此与我版本的repeat进行比较,我的版本是防弹的。

// Recur :: a -> Result a b
const Recur = (...args) => ({ recur: true, args });

// Return :: b -> Result a b
const Return = value => ({ recur: false, value });

// loop :: (a -> Result a b) -> a -> b
const loop = func => (...args) => {
    let result = func(...args);
    while (result.recur) result = func(...result.args);
    return result.value;
};

// repeat :: (Int, a -> a, a) -> a
const repeat = loop((n, f, x) => n === 0 ? Return(x) : Recur(n - 1, f, f(x)));

// finite loop
console.log(repeat(1, x => Recur(1, x), "wow, such hack, much loop"));

// reachable code
console.log("repeat wasn't hacked");

因此,未标记的联合是不安全的。然而,即使我们小心避免未标记的联合的陷阱,我仍然更喜欢标记的联合,因为在阅读和调试程序时,标记提供有用的信息。在我看来,标记使程序更易于理解和调试。
结论
引用Python禅宗
明确比隐式更好。
默认参数和未标记的联合是不好的,因为它们是隐式的,并且可能导致歧义。
Trampoline单子
现在,我想转换话题并谈论单子。接受的答案演示了一个堆栈安全的连续单子。但是,如果您只需要创建一个单调堆栈安全的递归函数,则不需要连续单子的全部功能。您可以使用Trampoline单子。
Trampoline单子是Loop单子的更强大的表亲,它只是将loop函数转换为单子。因此,让我们首先了解Loop单子。然后,我们将看到Loop单子的主要问题以及如何使用Trampoline单子来解决该问题。

// Recur :: a -> Result a b
const Recur = (...args) => ({ recur: true, args });

// Return :: b -> Result a b
const Return = value => ({ recur: false, value });

// Loop :: (a -> Result a b) -> a -> Loop b
const Loop = func => (...args) => ({ func, args });

// runLoop :: Loop a -> a
const runLoop = ({ func, args }) => {
    let result = func(...args);
    while (result.recur) result = func(...result.args);
    return result.value;
};

// pure :: a -> Loop a
const pure = Loop(Return);

// bind :: (Loop a, a -> Loop b) -> Loop b
const bind = (loop, next) => Loop(({ first, loop: { func, args } }) => {
    const result = func(...args);
    if (result.recur) return Recur({ first, loop: { func, args: result.args } });
    if (first) return Recur({ first: false, loop: next(result.value) });
    return result;
})({ first: true, loop });

// ack :: (Int, Int) -> Loop Int
const ack = (m, n) => {
    if (m === 0) return pure(n + 1);
    if (n === 0) return ack(m - 1, 1);
    return bind(ack(m, n - 1), n => ack(m - 1, n));
};

console.log(runLoop(ack(3, 4)));

请注意,loop已经被分成了一个Loop和一个runLoop函数。由Loop返回的数据结构是一个单子,而purebind函数实现了它的单子接口。我们使用purebind函数来编写阿克曼函数的简单实现。
不幸的是,ack函数不是堆栈安全的,因为它会递归调用自己,直到达到一个pure值。相反,我们希望ack对其归纳情况返回类似于Recur的数据结构。然而,Recur值的类型是Result而不是Loop。这个问题通过Trampoline单子得以解决。

// Bounce :: (a -> Trampoline b) -> a -> Trampoline b
const Bounce = func => (...args) => ({ bounce: true, func, args });

// Return :: a -> Trampoline a
const Return = value => ({ bounce: false, value });

// trampoline :: Trampoline a -> a
const trampoline = result => {
    while (result.bounce) result = result.func(...result.args);
    return result.value;
};

// pure :: a -> Trampoline a
const pure = Return;

// bind :: (Trampoline a, a -> Trampoline b) -> Trampoline b
const bind = (first, next) => first.bounce ?
    Bounce(args => bind(first.func(...args), next))(first.args) :
    next(first.value);

// ack :: (Int, Int) -> Trampoline Int
const ack = Bounce((m, n) => {
    if (m === 0) return pure(n + 1);
    if (n === 0) return ack(m - 1, 1);
    return bind(ack(m, n - 1), n => ack(m - 1, n));
});

console.log(trampoline(ack(3, 4)));

Trampoline数据类型是LoopResult的组合。 LoopRecur数据构造函数已合并为单个Bounce数据构造函数。 runLoop函数已简化并更名为trampolinepurebind函数也已简化。 实际上,pure只是Return。 最后,我们将Bounce应用于ack函数的原始实现。 Trampoline的另一个优点是它可以用于定义堆栈安全的相互递归函数。 例如,这里是Hofstadter Female and Male sequence函数的实现。

// Bounce :: (a -> Trampoline b) -> a -> Trampoline b
const Bounce = func => (...args) => ({ bounce: true, func, args });

// Return :: a -> Trampoline a
const Return = value => ({ bounce: false, value });

// trampoline :: Trampoline a -> a
const trampoline = result => {
    while (result.bounce) result = result.func(...result.args);
    return result.value;
};

// pure :: a -> Trampoline a
const pure = Return;

// bind :: (Trampoline a, a -> Trampoline b) -> Trampoline b
const bind = (first, next) => first.bounce ?
    Bounce(args => bind(first.func(...args), next))(first.args) :
    next(first.value);

// female :: Int -> Trampoline Int
const female = Bounce(n => n === 0 ? pure(1) :
    bind(female(n - 1), f =>
        bind(male(f), m =>
            pure(n - m))));

// male :: Int -> Trampoline Int
const male = Bounce(n => n === 0 ? pure(0) :
    bind(male(n - 1), m =>
        bind(female(m), f =>
            pure(n - f))));

console.log(Array.from({ length: 21 }, (_, n) => trampoline(female(n))).join(" "));
console.log(Array.from({ length: 21 }, (_, n) => trampoline(male(n))).join(" "));

编写单子代码的主要痛点是回调地狱。然而,使用生成器可以解决这个问题。

// Bounce :: (a -> Trampoline b) -> a -> Trampoline b
const Bounce = func => (...args) => ({ bounce: true, func, args });

// Return :: a -> Trampoline a
const Return = value => ({ bounce: false, value });

// trampoline :: Trampoline a -> a
const trampoline = result => {
    while (result.bounce) result = result.func(...result.args);
    return result.value;
};

// pure :: a -> Trampoline a
const pure = Return;

// bind :: (Trampoline a, a -> Trampoline b) -> Trampoline b
const bind = (first, next) => first.bounce ?
    Bounce(args => bind(first.func(...args), next))(first.args) :
    next(first.value);

// bounce :: (a -> Generator (Trampoline b)) -> a -> Trampoline b
const bounce = func => Bounce((...args) => {
    const gen = func(...args);

    const next = data => {
        const { value, done } = gen.next(data);
        return done ? value : bind(value, next);
    };

    return next(undefined);
});

// female :: Int -> Trampoline Int
const female = bounce(function* (n) {
    return pure(n ? n - (yield male(yield female(n - 1))) : 1);
});

// male :: Int -> Trampoline Int
const male = bounce(function* (n) {
    return pure(n ? n - (yield female(yield male(n - 1))) : 0);
});

console.log(Array.from({ length: 21 }, (_, n) => trampoline(female(n))).join(" "));
console.log(Array.from({ length: 21 }, (_, n) => trampoline(male(n))).join(" "));

最后,相互递归函数也展示了拥有一个独立的trampoline函数的优势。它允许我们调用返回Trampoline值的函数而不实际运行它。这使我们能够构建更大的Trampoline值,然后在需要时运行整个计算。

结论

如果您想编写间接或相互递归的堆栈安全函数,或者单子堆栈安全函数,则使用Trampoline单子。如果您想编写非单子直接递归的堆栈安全函数,则使用loop/recur/return模式。


3
类型的正确使用不仅仅是一个学术问题,还要考虑到你的代码可能在将来被转换成 TypeScript(即 fp-ts)的情况。很棒的回答! - user5536315
如果递归步骤也发生在bind的第一个参数中,即传递操作的地方(bind(ack(m, n - 1), n => ack(m - 1, n))),则递归有可能耗尽堆栈。Trampoline单子能否针对这种情况进行增强? - user5536315
你能给我一个栈耗尽的例子吗? - Aadit M Shah
这个顶部的循环/递归示例非常出色。如果您不介意,我可能会借用它。(当然要注明来源) - customcommander
很棒的帖子,Aadit。我一直想抽出时间更新一些关于这个主题的工作,并回应你的一些观点。在Bounce中,我注意到你将...args键入为a。你能以更少的手势方式输入它吗?我的猜测是需要为Bounce0Bounce1Bounce2等设置独立类型? - Mulan
1
@Mulan 在 Bounce 中输入 ...args 的一种方法是将类型变量限制为类似于数组的类型,就像在 TypeScript 中所做的那样。例如,declare const Bounce: <A extends unknown[], B>(func: (...args: A) => Trampoline<B>) => (...args: A) => Trampoline<B>。这是 行多态 的一个例子,它不需要为 Bounce0Bounce1Bounce2 等分别定义类型。不幸的是,在 TypeScript 中无法使用上述 Trampoline 的定义,因为它不支持存在量化。 - Aadit M Shah

2

在函数式编程范式中,编程意味着我们通过类型来表达算法。

将尾递归函数转换为堆栈安全版本需要考虑两种情况:

  • 基本情况
  • 递归情况

我们必须做出选择,并使用标记联合很好地解决了这个问题。然而,Javascript 没有这样的数据类型,因此我们要么创建一个,要么回退到 Object 编码。

对象编码

// simulate a tagged union with two Object types

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

// trampoline

const tailRec = f => (...args) => {
  let step = Loop(args);

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

  return step.value;
};

// stack-safe function

const repeat = n => f => x =>
  tailRec((Loop, Done, [m, y]) => m === 0
    ? Done(y)
    : Loop([m - 1, f(y)])) (n, x);

// run...

const inc = n =>
  n + 1;

console.time();
console.log(repeat(1e6) (inc) (0));
console.timeEnd();

函数编码

另一种方法是使用函数编码来创建一个真正的标记联合。现在,我们的风格更接近成熟的函数式语言:

// type/data constructor

const Type = Tcons => (tag, Dcons) => {
  const t = new Tcons();
  t.run = cases => Dcons(cases);
  t.tag = tag;
  return t;
};

// tagged union specific for the case

const Step = Type(function Step() {});

const Done = x =>
  Step("Done", cases => cases.Done(x));
  
const Loop = args =>
  Step("Loop", cases => cases.Loop(args));

// trampoline

const tailRec = f => (...args) => {
  let step = Loop(args);

  do {
    step = f(step);
  } while (step.tag === "Loop");

  return step.run({Done: id});
};

// stack-safe function

const repeat = n => f => x => 
  tailRec(step => step.run({
    Loop: ([m, y]) => m === 0 ? Done(y) : Loop([m - 1, f(y)]),
    Done: y => Done(y)
  })) (n, x);

// run...

const inc = n => n + 1;
const id = x => x;

console.log(repeat(1e6) (inc) (0));


1
我正在尝试各种方法来实现一个像你的eager一样能够容纳柯里化函数的函数 - repeat = n => f => x => ... vs repeat = (n, f, x) =>,但是我遇到了一些问题。顺便说一句,我会非常小心地使用typeof g === 'function'作为循环条件,因为函数通常会返回其他函数,并且有时可能会误判eager。这就是为什么我在我的trampoline中使用了特定的Bounce容器。无论如何,像往常一样,非常好的答案^_^ - Mulan

1

还可以参考 unfold (来自 Ramda 文档)

从种子值构建一个列表。接受一个迭代器函数,该函数返回 false 以停止迭代或长度为 2 的数组, 包含要添加到结果列表中的值和在下一次调用迭代器函数时要使用的种子。

var r = n => f => x => x > n ? false : [x, f(x)];
var repeatUntilGreaterThan = n => f => R.unfold(r(n)(f), 1);
console.log(repeatUntilGreaterThan(10)(x => x + 1));
<script src="https://cdnjs.cloudflare.com/ajax/libs/ramda/0.22.1/ramda.min.js"></script>


Ramda的unfold函数在底层使用了一个while循环,因此不确定是否有任何优化(尽管肯定更加清晰)。 - Drenai

0

我一直在思考这个问题。最近我遇到了需要使用函数式 while 循环的情况。

对我来说,这个问题似乎只是想要一种内联 while 循环的方法。确实有一种使用闭包的方法可以做到这一点。

"some string "+(a=>{
   while(comparison){
      // run code
   }
   return result;
})(somearray)+" some more"

或者,如果你想要的是一个基于数组进行链式操作的方法,那么你可以使用 reduce 方法。

somearray.reduce((r,o,i,a)=>{
   while(comparison){
      // run code
   }
   a.splice(1); // This would ensure only one call.
   return result;
},[])+" some more"

这并没有将我们的核心while循环转换为函数。但它确实允许我们使用内联循环。我只是想与任何可能需要帮助的人分享这个。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接