繁体   English   中英

尾调用优化递归函数

[英]Tail Call Optimizing recursive function

这是一个深度展平数组的函数

const deepFlatten = (input) => {
  let result = [];
  input.forEach((val, index) => {
    if (Array.isArray(val)) {
      result.push(...deepFlatten(val));
    } else {
      result.push(val);
    }
  });
  return result;
};

在讨论过程中,我被告知它不具有内存效率,因为它可能会导致堆栈溢出。

我在http://2ality.com/2015/06/tail-call-optimization.html中读到我可能会重写它以便它被TCO编辑。

它会是什么样子,我怎么能测量它的内存使用情况?

尾调用一般

我已经分享了另一种在JavaScript中展平数组的功能方法 ; 我认为答案显示了解决这一特定问题的更好方法,但并非所有函数都可以很好地分解。 这个答案将集中在尾部调用递归函数,和尾一般要求

通常,为了将重复调用移动到尾部位置,创建辅助函数(下面的aux ),其中函数的参数保持完成该计算步骤的所有必要状态。

 const flattenDeep = arr => { const aux = (acc, [x,...xs]) => x === undefined ? acc : Array.isArray (x) ? aux (acc, x.concat (xs)) : aux (acc.concat (x), xs) return aux ([], arr) } const data = [0, [1, [2, 3, 4], 5, 6], [7, 8, [9]]] console.log (flattenDeep (data)) // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ] 

js实际上没有尾调用消除

但是,大多数JavaScript实现仍然不支持尾调用 - 如果你想在你的程序中使用递归并且不用担心乱堆,你必须以不同的方式处理它 - 这也是我已经写了很多关于也是

我目前的首选是clojure风格的loop / recur对,因为它可以为您提供堆栈安全性,同时使用漂亮,纯粹的表达式来编写程序

 const recur = (...values) => ({ type: recur, values }) const loop = f => { let acc = f () while (acc && acc.type === recur) acc = f (...acc.values) return acc } const flattenDeep = arr => loop ((acc = [], [x,...xs] = arr) => x === undefined ? acc : Array.isArray (x) ? recur (acc, x.concat (xs)) : recur (acc.concat (x), xs)) let data = [] for (let i = 2e4; i>0; i--) data = [i, data] // data is nested 20,000 levels deep // data = [1, [2, [3, [4, ... [20000, []]]]]] ... // stack-safe ! console.log (flattenDeep (data)) // [ 1, 2, 3, 4, ... 20000 ] 


一个重要的位置

为什么尾部位置如此重要呢? 你有没有想过那个return关键字? 这是你函数的方式; 并且在像JavaScript这样严格评估的语言中, return <expr>意味着在我们发送结果之前需要计算expr中的所有内容。

如果expr包含一个子表达式,其函数调用不在尾部位置,那些调用将引入一个新帧,计算一个中间值,然后将其返回到调用帧以进行尾调用 - 这就是堆栈可能溢出的原因如果没有办法确定何时可以安全地移除堆栈帧

无论如何,很难谈论编程,所以希望这个小草图有助于识别某些常见功能中的呼叫位置

 const add = (x,y) => // + is in tail position x + y const sq = x => // * is in tail position x * x const sqrt = x => // Math.sqrt is in tail position Math.sqrt (x) const pythag = (a,b) => // sqrt is in tail position // sq(a) and sq(b) must *return* to compute add // add must *return* to compute sqrt sqrt (add (sq (a), sq (b))) // console.log displays the correct value becaust pythag *returns* it console.log (pythag (3,4)) // 5 

在这里坚持一分钟 - 现在想象没有返回值 - 因为函数无法将值发送回调用者,当然我们可以很容易地推断在评估函数后可以立即丢弃所有帧

// instead of
const add = (x,y) =>
  { return x + y }

// no return value
const add = (x,y) =>
  { x + y }

// but then how do we get the computed result?
add (1,2) // => undefined

延续传球风格

输入Continuation Passing Style - 通过为每个函数添加一个continuation参数,就像我们发明了自己的返回机制一样

不要被下面的例子所淹没 - 大多数人已经看到了这些被误解的东西称为回调的延续传递方式

// jQuery "callback"
$('a').click (event => console.log ('click event', event))

// node.js style "callback"
fs.readFile ('entries.txt', (err, text) =>
  err
    ? console.error (err)
    : console.log (text))

这就是你如何处理计算结果 - 你将它传递给一个延续

 // add one parameter, k, to each function // k makes *return* into a normal function // note {}'s are used to suppress the implicit return value of JS arrow functions const add = (x,y,k) => { k (x + y) } const sq = (x,k) => { k (x * x) } const sqrt = (x,k) => { k (Math.sqrt (x)) } const pythag = (a,b,k) => // sq(a) is computed, $a is the result sq (a, $a => { // sq(b) is computed, $b is the result sq (b, $b => { // add($a,$b) is computed, $sum is the result add ($a, $b, $sum => { // sqrt ($sum) is computed, conintuation k is passed thru sqrt ($sum, k) }) }) }) // here the final continuation is to log the result // no *return* value was used ! // no reason to keep frames in the stack ! pythag (3, 4, $c => { console.log ('pythag', $c) }) 

如何获得价值?

这个着名的问题: 如何从异步调用返回响应? 令数百万程序员感到困惑 - 只是,它实际上与“异步调用”无关,而且与延续无关,以及这些延续是否会返回任何内容

  // nothing can save us...
  // unless pythag *returns*
  var result = pythag (3,4, ...)
  console.log (result) // undefined

如果没有返回值,则必须使用continuation将值移动到计算中的下一步 - 这不是我试图说的第一种方式^^

但一切都处于尾部位置!

我知道通过查看它可能很难说,但是每个函数在尾部位置只有一个函数调用 - 如果我们在函数中恢复返回功能,则调用1的值是调用2的值是值调用3等等 - 在这种情况下不需要为后续呼叫引入新的堆栈帧 - 相反,呼叫1的帧可以重新用于呼叫2,然后再次重新用于呼叫3; 我们仍然保持回报价值!

 // restore *return* behaviour const add = (x,y,k) => k (x + y) const sq = (x,k) => k (x * x) const sqrt = (x,k) => k (Math.sqrt (x)) const pythag = (a,b,k) => sq (a, $a => sq (b, $b => add ($a, $b, $sum => sqrt ($sum, k)))) // notice the continuation returns a value now: $c // in an environment that optimises tail calls, this would only use 1 frame to compute pythag const result = pythag (3, 4, $c => { console.log ('pythag', $c); return $c }) // sadly, the environment you're running this in likely took almost a dozen // but hey, it works ! console.log (result) // 5 

尾巴叫一般; 再次

将“正常”函数转换为连续传递样式函数可以是一个机械过程并自动完成 - 但是将所有内容置于尾部位置的真正意义何在?

好吧,如果我们知道第1帧的值是第2帧的值,即第3帧的值,依此类推,我们可以手动折叠堆栈帧使用while循环,其中计算结果在每次迭代期间就地更新- 利用这种技术的功能称为蹦床

当然,在编写递归函数时,最常谈到蹦床,因为递归函数可以多次“反弹”(产生另一个函数调用); 甚至是无限期 - 但这并不意味着我们不能在我们的pythag函数上展示一个仅产生一些call的蹦床

 const add = (x,y,k) => k (x + y) const sq = (x,k) => k (x * x) const sqrt = (x,k) => k (Math.sqrt (x)) // pythag now returns a "call" // of course each of them are in tail position ^^ const pythag = (a,b,k) => call (sq, a, $a => call (sq, b, $b => call (add, $a, $b, $sum => call (sqrt, $sum, k)))) const call = (f, ...values) => ({ type: call, f, values }) const trampoline = acc => { // while the return value is a "call" while (acc && acc.type === call) // update the return value with the value of the next call // this is equivalent to "collapsing" a stack frame acc = acc.f (...acc.values) // return the final value return acc } // pythag now returns a type that must be passed to trampoline // the call to trampoline actually runs the computation const result = trampoline (pythag (3, 4, $c => { console.log ('pythag', $c); return $c })) // result still works console.log (result) // 5 


你为什么要告诉我这一切?

因此,即使我们的环境不支持堆栈安全递归,只要我们将所有内容保持在尾部位置并使用我们的call帮助器,我们现在可以将任何堆栈的调用转换为循环

// doesn't matter if we have 4 calls, or 1 million ... 
trampoline (call (... call (... call (...))))

在第一个代码示例中,我展示了使用auxiliary循环,但我还使用了一个非常聪明(尽管效率低)的循环,它不需要深度重复进入数据结构 - 有时这并不总是可行的; 例如,有时您的递归函数可能会产生2或3个重复调用 - 那么该怎么做?

下面我要告诉你flatten作为一个天真的,非尾递归过程-注意这里重要的是, 两个重复呼叫的条件结果的一个分支,以flatten -这树一样重复过程似乎很难拉平首先进入迭代循环,但仔细,机械地转换为延续传递样式将显示此技术几乎可以在任何(如果不是全部)场景中使用

[ 草案 ]

// naive, stack-UNSAFE
const flatten = ([x,...xs]) =>
  x === undefined
    ? []
    : Array.isArray (x)
      // two recurring calls
      ? flatten (x) .concat (flatten (xs))
      // one recurring call
      : [x] .concat (flatten (xs))

延续传球风格

// continuation passing style
const flattenk = ([x,...xs], k) =>
  x === undefined
    ? k ([])
    : Array.isArray (x)
      ? flattenk (x, $x =>
          flattenk (xs, $xs =>
            k ($x.concat ($xs))))
      : flattenk (xs, $xs =>
          k ([x].concat ($xs)))

用蹦床延续传球风格

 const call = (f, ...values) => ({ type: call, f, values }) const trampoline = acc => { while (acc && acc.type === call) acc = acc.f (...acc.values) return acc } const flattenk = ([x,...xs], k) => x === undefined ? call (k, []) : Array.isArray (x) ? call (flattenk, x, $x => call (flattenk, xs, $xs => call (k, $x.concat ($xs)))) : call (flattenk, xs, $xs => call (k, ([x].concat ($xs)))) const flatten = xs => trampoline (flattenk (xs, $xs => $xs)) let data = [] for (let i = 2e4; i>0; i--) data = [i, data]; console.log (flatten (data)) 


wups,你不小心发现了monads

[ 草案 ]

 // yours truly, the continuation monad const cont = x => k => k (x) // back to functions with return values // notice we don't need the additional `k` parameter // but this time wrap the return value in a continuation, `cont` // ie, `cont` replaces *return* const add = (x,y) => cont (x + y) const sq = x => cont (x * x) const sqrt = x => cont (Math.sqrt (x)) const pythag = (a,b) => // sq(a) is computed, $a is the result sq (a) ($a => // sq(b) is computed, $b is the result sq (b) ($b => // add($a,$b) is computed, $sum is the result add ($a, $b) ($sum => // sqrt ($sum) is computed, a conintuation is returned sqrt ($sum)))) // here the continuation just returns whatever it was given const $c = pythag (3, 4) ($c => $c) console.log ($c) // => 5 


分隔的延续

[ 草案 ]

 const identity = x => x const cont = x => k => k (x) // reset const reset = m => k => m (k) // shift const shift = f => k => f (x => k (x) (identity)) const concatMap = f => ([x,...xs]) => x === undefined ? [ ] : f (x) .concat (concatMap (f) (xs)) // because shift returns a continuation, we can specialise it in meaningful ways const amb = xs => shift (k => cont (concatMap (k) (xs))) const pythag = (a,b) => Math.sqrt (Math.pow (a, 2) + Math.pow (b, 2)) const pythagTriples = numbers => reset (amb (numbers) ($x => amb (numbers) ($y => amb (numbers) ($z => // if x,y,z are a pythag triple pythag ($x, $y) === $z // then continue with the triple ? cont ([[ $x, $y, $z ]]) // else continue with nothing : cont ([ ]))))) (identity) console.log (pythagTriples ([ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ])) // [ [ 3, 4, 5 ], [ 4, 3, 5 ], [ 6, 8, 10 ], [ 8, 6, 10 ] ] 

当递归调用在forEach时,您无法对其进行优化,因为为了应用TCO,编译器需要检查您是否保存了前一次调用的“状态”。 forEach情况下,您确实保存当前位置的“状态”。

为了使用TCO实现它,您可以重写使用递归调用实现的foreach ,它看起来像这样:

 function deepFlattenTCO(input) { const helper = (first, rest, result) => { if (!Array.isArray(first)) { result.push(first); if (rest.length > 0) { return helper(rest, [], result); } else { return result; } } else { const [newFirst, ...newRest] = first.concat(rest); return helper(newFirst, newRest, result); } }; return helper(input, [], []); } console.log(deepFlattenTCO([ [1], 2, [3], 4, [5, 6, [7]] ])); 

您可以看到,在每个return中,执行的唯一操作是递归调用,因此,您不会在递归调用之间保存“状态”,因此编译器将应用优化。

递归函数优雅地表达,尾递归优化甚至可以防止它们吹掉堆栈。

但是,任何递归函数都可以转换为基于丑陋迭代器的解决方案,这可能仅在其内存消耗和性能方面很漂亮,但不要看。

请参阅: 在Javascript中展平第n个嵌套数组的迭代解决方案

也许这种不同方法的测试: https//jsperf.com/iterative-array-flatten/2

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM