简体   繁体   English

尾调用优化递归函数

[英]Tail Call Optimizing recursive function

This is a function which deep-flattens an array 这是一个深度展平数组的函数

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

During a discussion, I was told it is not memory efficient, as it might cause stack overflows. 在讨论过程中,我被告知它不具有内存效率,因为它可能会导致堆栈溢出。

I read in http://2ality.com/2015/06/tail-call-optimization.html that I could potentially re-write it so that it is TCO-ed. 我在http://2ality.com/2015/06/tail-call-optimization.html中读到我可能会重写它以便它被TCO编辑。

How would that look like and how could I measure it's memory usage profile? 它会是什么样子,我怎么能测量它的内存使用情况?

tail calls in general 尾调用一般

I've shared another functional approach to flattening arrays in JavaScript ; 我已经分享了另一种在JavaScript中展平数组的功能方法 ; I think that answer shows a better way to solve this particular problem, but not all functions can be decomposed so nicely. 我认为答案显示了解决这一特定问题的更好方法,但并非所有函数都可以很好地分解。 This answer will focus on tail calls in recursive functions, and tail calls in general 这个答案将集中在尾部调用递归函数,和尾一般要求

In general, to move the recurring call into tail position, an auxiliary function ( aux below) is created where the parameters of the function holds all the necessary state to complete that step of the computation 通常,为了将重复调用移动到尾部位置,创建辅助函数(下面的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 doesn't really have tail call elimination js实际上没有尾调用消除

However, most JavaScript implementations still don't support tail calls - you'll have to approach this differently if you want to use recursion in your program and not worry about blowing the stack - this is also something I've already written a lot about, too 但是,大多数JavaScript实现仍然不支持尾调用 - 如果你想在你的程序中使用递归并且不用担心乱堆,你必须以不同的方式处理它 - 这也是我已经写了很多关于也是

My current go-to is the clojure-style loop / recur pair because it gives you stack safety while simultaneously affording your program to be written using a beautiful, pure expression 我目前的首选是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 ] 


an important position 一个重要的位置

why is tail position so important anyway? 为什么尾部位置如此重要呢? well have you ever thought about that return keyword? 你有没有想过那个return关键字? That's the way out of your function; 这是你函数的方式; and in a strictly-evaluated language like JavaScript, return <expr> means everything in expr needs to be computed before we can send the result out. 并且在像JavaScript这样严格评估的语言中, return <expr>意味着在我们发送结果之前需要计算expr中的所有内容。

If expr contains a sub-expression with function calls that are not in tail position, those calls will introduce a new frame, compute an intermediate value, and then return it to the calling frame for the tail call – which is why the stack can overflow if there's no way to identify when it's safe to remove a stack frame 如果expr包含一个子表达式,其函数调用不在尾部位置,那些调用将引入一个新帧,计算一个中间值,然后将其返回到调用帧以进行尾调用 - 这就是堆栈可能溢出的原因如果没有办法确定何时可以安全地移除堆栈帧

Anyway, it's hard to talk about programming, so hopefully this small sketch helps identify calling positions in some common functions 无论如何,很难谈论编程,所以希望这个小草图有助于识别某些常见功能中的呼叫位置

 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 

Stick with me here for a minute – now imagine there was no return values – since a function has no way to send a value back to the caller, of course we could easily reason that all frames can be immediately discarded after the function has been evaluated 在这里坚持一分钟 - 现在想象没有返回值 - 因为函数无法将值发送回调用者,当然我们可以很容易地推断在评估函数后可以立即丢弃所有帧

// 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 延续传球风格

Enter Continuation Passing Style – by adding a continuation parameter to each function, it's as if we invent our very own return mechanism 输入Continuation Passing Style - 通过为每个函数添加一个continuation参数,就像我们发明了自己的返回机制一样

Don't get overwhelmed by the examples below – most people have already seen continuation passing style in the form of these misunderstood things called callbacks 不要被下面的例子所淹没 - 大多数人已经看到了这些被误解的东西称为回调的延续传递方式

// 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))

So that's how you work with the computed result – you pass it to a continuation 这就是你如何处理计算结果 - 你将它传递给一个延续

 // 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) }) 

how to get a value out? 如何获得价值?

This famous question: How do I return the response from an asynchronous call? 这个着名的问题: 如何从异步调用返回响应? has baffled millions of programmers – only, it really has nothing to do with "an asynchronous call" and everything to do with continuations and whether those continuations return anything 令数百万程序员感到困惑 - 只是,它实际上与“异步调用”无关,而且与延续无关,以及这些延续是否会返回任何内容

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

Without a return value, you must use a continuation to move the value to the next step in the computation – this can't be the first way I've tried to say that ^^ 如果没有返回值,则必须使用continuation将值移动到计算中的下一步 - 这不是我试图说的第一种方式^^

but everything is in tail position ! 但一切都处于尾部位置!

I know it might be hard to tell just by looking at it, but every function has exactly one function call in tail position – if we restore the return functionality in our functions, the value of call 1 is the value of call 2 is the value of call 3, etc – there's no need to introduce a new stack frame for subsequent calls in this situation – instead, call 1's frame can be re-used for call 2, and then re-used again for call 3; 我知道通过查看它可能很难说,但是每个函数在尾部位置只有一个函数调用 - 如果我们在函数中恢复返回功能,则调用1的值是调用2的值是值调用3等等 - 在这种情况下不需要为后续呼叫引入新的堆栈帧 - 相反,呼叫1的帧可以重新用于呼叫2,然后再次重新用于呼叫3; and we still get to keep the return value ! 我们仍然保持回报价值!

 // 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 

tail calls in general; 尾巴叫一般; again 再次

this conversion of a "normal" function to a continuation passing style function can be a mechanical process and done automatically – but what's the real point of putting everything into tail position? 将“正常”函数转换为连续传递样式函数可以是一个机械过程并自动完成 - 但是将所有内容置于尾部位置的真正意义何在?

Well if we know that frame 1's value is the value of frame 2's, which is the value of frame 3's, and so on, we can collapse the stack frames manually use a while loop where the computed result is updated in-place during each iteration – a function utilising this technique is called a trampoline 好吧,如果我们知道第1帧的值是第2帧的值,即第3帧的值,依此类推,我们可以手动折叠堆栈帧使用while循环,其中计算结果在每次迭代期间就地更新- 利用这种技术的功能称为蹦床

Of course trampolines are most often talked about when writing recursive functions because a recursive function could "bounce" (spawn another function call) many times; 当然,在编写递归函数时,最常谈到蹦床,因为递归函数可以多次“反弹”(产生另一个函数调用); or even indefinitely – but that doesn't mean we can't demonstrate a trampoline on our pythag function that would only spawn a few call s 甚至是无限期 - 但这并不意味着我们不能在我们的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 


why are you showing me all of this? 你为什么要告诉我这一切?

So even tho our environment doesn't support stack-safe recursion, as long as we keep everything in tail position and use our call helper, we can now convert any stack of calls into a loop 因此,即使我们的环境不支持堆栈安全递归,只要我们将所有内容保持在尾部位置并使用我们的call帮助器,我们现在可以将任何堆栈的调用转换为循环

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

In the first code example, I showed using an auxiliary loop, but I also used a pretty clever (albeit inefficient) loop that didn't require deep recurring into the data structure – sometimes that's not always possible; 在第一个代码示例中,我展示了使用auxiliary循环,但我还使用了一个非常聪明(尽管效率低)的循环,它不需要深度重复进入数据结构 - 有时这并不总是可行的; eg, sometimes your recursive function might spawn 2 or 3 recurring calls – what to do then ? 例如,有时您的递归函数可能会产生2或3个重复调用 - 那么该怎么做?

Below I'm going to show you flatten as a naive, non-tail recursive procedure – what's important to note here is that one branch of the conditional results in two recurring calls to flatten – this tree-like recurring process might seem hard to flatten into an iterative loop at first, but a careful, mechanical conversion to continuation passing style will show this technique can work in almost any (if not all) scenarios 下面我要告诉你flatten作为一个天真的,非尾递归过程-注意这里重要的是, 两个重复呼叫的条件结果的一个分支,以flatten -这树一样重复过程似乎很难拉平首先进入迭代循环,但仔细,机械地转换为延续传递样式将显示此技术几乎可以在任何(如果不是全部)场景中使用

[ DRAFT ] [ 草案 ]

// 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 延续传球风格

// 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)))

Continuation passing style with trampoline 用蹦床延续传球风格

 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, you accidentally discovered monads wups,你不小心发现了monads

[ DRAFT ] [ 草案 ]

 // 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 


delimited continuations 分隔的延续

[ DRAFT ] [ 草案 ]

 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 ] ] 

You can't optimize it when your recursive call is inside forEach , because in order to apply TCO, the compiler need to check that you not saving a "state" of the previous call. 当递归调用在forEach时,您无法对其进行优化,因为为了应用TCO,编译器需要检查您是否保存了前一次调用的“状态”。 In case of forEach you do save a "state" of the current position. forEach情况下,您确实保存当前位置的“状态”。

In order to implement it with TCO you can rewrite that foreach to be implemented with the recursive call, it would look something like that: 为了使用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]] ])); 

You can see that in each return the only operation that is executed is the recursive call, so, you don't save "state" between recursive calls, therefore the compiler will apply the optimization. 您可以看到,在每个return中,执行的唯一操作是递归调用,因此,您不会在递归调用之间保存“状态”,因此编译器将应用优化。

Recursive functions are elegantly expressed, and tail recursion optimization can even prevent them from blowing the stack. 递归函数优雅地表达,尾递归优化甚至可以防止它们吹掉堆栈。

However, any recursive function can be converted to an uglier iterator based solution, which might be beautiful only in its memory consumption and performance, though not to look at. 但是,任何递归函数都可以转换为基于丑陋迭代器的解决方案,这可能仅在其内存消耗和性能方面很漂亮,但不要看。

See: Iterative solution for flattening n-th nested arrays in Javascript 请参阅: 在Javascript中展平第n个嵌套数组的迭代解决方案

and perhaps this test of different approaches: https://jsperf.com/iterative-array-flatten/2 也许这种不同方法的测试: https//jsperf.com/iterative-array-flatten/2

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

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