ELSE
首发于ELSE
[译]All About Recursion, PTC, TCO and STC in JavaScript

[译]All About Recursion, PTC, TCO and STC in JavaScript

原文地址 : All About Recursion, PTC, TCO and STC in JavaScript (已获作者授权翻译及发布)

近来人们很热衷于函数式编程及概念。然而,很多人却忽略了递归,特别是其正确的尾部调用,对于如何编写干净整洁的不爆栈的代码意义重大。

本文为你提供更好的可视化提示及递归思考并解释什么是正确的尾部调用,尾部调用优化,句法尾部调用及如何区分他们,他们如何工作及他们在javascript引擎中的实现。

同时本文也会提及到很多关于调用堆栈,堆栈跟踪,但不会太细节。如果想要了解更多,可以阅读这篇文章


一句话简述递归

一个问题的解决方案依赖于相同解决方案应用于不同实例时候,就会产生递归。

举个例子,4的阶层可以被定义为3的阶层乘以4.

这意味着数字的阶层可以被自己定义(数字的阶层可以被数字的阶层定义)

factorial(5) = factorial(4) * 5
factorial(5) = factorial(3) * 4 * 5
factorial(5) = factorial(2) * 3 * 4 * 5
factorial(5) = factorial(1) * 2 * 3 * 4 * 5
factorial(5) = factorial(0) * 1 * 2 * 3 * 4 * 5
factorial(5) = 1 * 1 * 2 * 3 * 4 * 5

简而言之,当一个函数调用自身,我们就可以称之为递归。

思考高效递归

当我在思考递归的时候,我会想象有多个分支分别在执行推导第一次的执行然后把结果冒泡到根调用

在上面的阶层例子中,我们可以看到多个调用都是来自于我们定义的自身存在的第一个单元(在此例子中,阶层0等于1,接下来把这个结果返回(冒泡),因此我们可以处理另一个基于此结果的操作然后再返回一个值,一直重复这个过程直到把结果返回'根'调用)

如果我们要表示在参数值为5时阶层函数的调用,这个过程就可以表示为:

通过一个平行的编译器理论,这个过程看起来我们使用无上下文语法去推导句子直到最后的值。

第一次看起来可能会比较抽象,但是让我在视觉上演示通过剖析对斐波那契序数中第N个函数的调用,来看这个想法是如何奏效的。


下面是斐波那契函数:

// N is the Nth fibonacci
function fibonacci(n) {
   if (n < 2) {
     return 1
   } else {
     return fibonacci(n - 2) + fibonacci(n - 1)
   }
}

基本上,每个该函数的调用会产生两个调用,这些调用也可能会调用自身直到返回的结果值小于2(因为斐波那契序列是以1,1求和开始的)

当一个数字小于2时,返回这个结果,这样就能被上面调用,一直向上冒泡结果直到根调用

如下图清楚地演示了,当调用 factorial(4) 时,在达到一个能"自我满足"(满足if)的定义时,我们就会停止继续推导,在此例中即为在斐波那契序列前两个数字: 1 (第零个) 和 1(第一个)

既然每个递归调用都依赖于另外两个递归调用的结果(直到值小于2),我们开始从叶节点(1)返回值然后求和以便于上面的调用

你可能注意到上面例子中,我们可以有线性递归(递归调用分支只有一个的时候,如阶层)和分支递归(递归调用分支有多个的时候,如斐波那契)

在思考递归的时候,有两个主要的东西需要思考:

1.定义一个存在的条件,一个自身存在或满足的原子级的定义(也称为"基本案例")

2.定义算法的哪一部分为递归

在你定义完一个存在的条件之后,就很容易判断一个函数什么时候应该再调用自身,什么时候应该处理结果

如果想阅读更多实际且有趣的递归应用场景,可以看看树和图形相关算法的工作原理

递归和调用堆栈

一般来说,当使用递归时,我们最终将把函数堆叠到另一个之上,因为他们可以依赖于之前调用自身的结果。

如果想要更好理解堆栈的工作原理或者如何读取堆栈跟踪,可以查看这篇

为了演示当递归发生时调用堆栈的样子,我们通过一个简单的阶层函数作为例子.

代码如下:

function factorial(n) {
    if (n === 0) {
        return 1
    }

    return n * factorial(n - 1)
}

现在调用该函数,传入参数为3

你应该还记得之前的例子,3的阶层是由2阶层,1的阶层和0的阶层乘以自身组成的。这意味着一个简答的3的阶层的调用取回了超过3个阶层的调用函数


每个这些调用都会把一个新的堆栈帧放到调用堆栈中,所以当它们都入栈了之后,看起来有点像这样:
factorial(0) // The factorial of 0 is 1 by definition (base case)
factorial(1) // This call depends on factorial(0)
factorial(2) // This call depends on factorial(1)
factorial(3) // This first call depends on factorial(2)


现在让我们添加console.trace以便于查看阶层函数被调用时的堆栈中的当前堆栈帧。

你的代码现在看起来应该像这样:

function factorial(n) {
    console.trace()
    if (n === 0) {
        return 1
    }

    return n * factorial(n - 1)
}

factorial(3) // Let's call the factorial function and see what happens

现在运行这段代码,分析每一个打印的调用栈

首先:

Trace
    at factorial (repl:2:9)
    at repl:1:1 // Ignore everything below this line, it's just implementation details
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12)
    at REPLServer.onLine (repl.js:513:10)
    at emitOne (events.js:101:20)

如你所见,第一个调用堆栈仅包含第一次调用factorial 函数,也就是factorial(3),现在,有趣的事要开始了

Trace
    at factorial (repl:2:9)
    at factorial (repl:7:12)
    at repl:1:1 // Ignore everything below this line, it's just implementation details
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12)
    at REPLServer.onLine (repl.js:513:10)

这里有另一个调用factorial 函数,且就在上一个上面。这个调用就是 factorial(2)

接下来就是调用factorial(1)的栈:

Trace
    at factorial (repl:2:9)
    at factorial (repl:7:12)
    at factorial (repl:7:12)
    at repl:1:1
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12)

正如你所见,在之前的堆栈上又多了一个调用.

最后,当执行到 factorial(0)时候的调用堆栈:

Trace
    at factorial (repl:2:9)
    at factorial (repl:7:12)
    at factorial (repl:7:12)
    at factorial (repl:7:12)
    at repl:1:1
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)

正如我在之前开头说的,factorial(3)的首次调用需要调用factorial(2), factorial(1) and factorial(0).这也就是为什么在栈中有4项factorial 的函数

你可能也意识到了当递归太多次时会遇到什么问题。调用堆栈会越来越大直到堆栈缓冲区溢出。当堆栈达到上限,试着添加另一项到调用堆栈时候,就会发生堆栈缓冲区溢出。

如果你想计算出在你运行javascript代码的环境中,能够拥有多少堆栈帧上限的话,推荐看这篇

正确的尾部调用(PTC)

ES6出来的时候,正确的尾部调用就应该被实施,但是由于各种原因,大部分的js引擎还都不可用,文章后面会解释.

PTC允许我们在递归调用时避免爆栈。然而为了实现PTC,我们首先需要一个尾部调用。

但是什么是尾部调用呢?

尾部调用就是函数可以在不增加堆栈的情况下被执行。他们通常总是在函数返回及被调函数的值返回之前最后被完成和评估的。调用函数不能为生成器函数。

如果你喜欢编译原理及这种hardcore(或者说很酷)的东西。可以阅读这篇


为了演示PTC的工作原理,我们需要重构我们的factorial函数,使他变成尾部递归:

// If total is not provided we assign 1 to it
function factorial(n, total = 1) {
    if (n === 0) {
        return total
    }

    return factorial(n - 1, n * total)
}

现在该函数最后要做的仅仅就是返回调用自己的结果,这也变成了尾部递归

你可能注意到我们现在传递了两个参数:下一次我们想要计算的factorial (n - 1)的值以及已经计算好的总值,即 n*total

既然已经有满足当前状态的所需的所有值(已计算的值及下一次计算的值),我们就不需要达到衍生的函数调用的叶节点(如上面例子)

让我们来分析这个函数是如何在不增加堆栈的情况下实现的。

当我们调用factorial(4)时候,发生了这些事:


  1. 一个调用factorial 的堆栈帧被加到栈顶
  2. 既然4不等于0(基本案例),我们判定下一个值我们需要去计算(3)和当前已计算的值(4 * total (1为默认值))
  3. 现在,当再一次调用factorial 时,它会接收到执行过程所需要的所有数据:下一个需要被计算的factorial 及当前已经被计算的总值. 也正是因为如此,我们才不需要诸如之前状态的堆栈帧,所以把那一帧移出栈然后新增 对factorial(3,4)的调用
  4. 调用依然大于0,所以我们通过当前值(3)计算下一次factorial 乘以计算的值(4).
  5. 刚才的调用帧又不需要了,所以我们又把他移出栈。把2和12作为参数传递到下一个调用。再一次我们就能把total值更新为24且计算到factorial (1)
  6. 之前的帧从栈中移除,24*1然后计算factorial (0).
  7. 最后factorial (0)返回计算的total值,也就是24

总而言之,它过程是这样发生的:

factorial(4, 1) // 1 is the default value when nothing gets passed
factorial(3, 4) // This call does not need the previous one, it has all the data it needs
factorial(2, 12) // This call does not need the previous one, it has all the data it needs
factorial(1, 24) // This call does not need the previous one, it has all the data it needs
factorial(0, 24) // -> Returns the total (24) and also does not need the previous one

现在,我们从n个栈帧变成了1个栈帧,既然后面的调用不再取决于之前的,这同时也让我们把内存(空间)复杂度从O(N)降到O(1)

在Node中使用PTC


如果你添加console.trace函数调用在上面的函数,然后调用factorial(3)以查看栈的调用,像这样:

function factorial(n, total = 1) {
    console.trace()
    if (n === 0) {
        return total
    }

    return factorial(n - 1, n * total)
}

factorial(3)

你会看到依然会有factorial函数栈的调用即使我们说这个函数是尾部递归:

// ...
// These are the last two calls to the `factorial` function
Trace
    at factorial (repl:2:9) // Here we have 3 calls stacked
    at factorial (repl:7:8)
    at factorial (repl:7:8)
    at repl:1:1 // Implementation details below this line
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12)
Trace
    at factorial (repl:2:9) // The last call added one more frame to our stack
    at factorial (repl:7:8)
    at factorial (repl:7:8)
    at factorial (repl:7:8)
    at repl:1:1 // Implementation details below this line
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)

为了能够访问Node的PTC,我们通过添加'use strict'在js文件顶部启用严格模式(strict mode),然后用命令行运行添加以下指令


--harmony_tailcalls

为了这命令行指令能够提高我们的factorial 函数,代码应该是像这样的:

'use strict'

function factorial(n, total = 1) {
    console.trace()
    if (n === 0) {
        return total
    }

    return factorial(n - 1, n * total)
}

factorial(3)

命令行运行


$ node --harmony_tailcalls factorial.js

再一次运行代码,这些就是我们获得的堆栈跟踪:

Trace
    at factorial (/Users/lucasfcosta/factorial.js:4:13)
    at Object.<anonymous> (/Users/lucasfcosta/factorial.js:12:1)
    at Module._compile (module.js:571:32)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:488:32)
    at tryModuleLoad (module.js:447:12)
    at Function.Module._load (module.js:439:3)
    at Module.runMain (module.js:605:10)
    at run (bootstrap_node.js:420:7)
    at startup (bootstrap_node.js:139:9)
Trace
    at factorial (/Users/lucasfcosta/factorial.js:4:13)
    at Object.<anonymous> (/Users/lucasfcosta/factorial.js:12:1)
    at Module._compile (module.js:571:32)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:488:32)
    at tryModuleLoad (module.js:447:12)
    at Function.Module._load (module.js:439:3)
    at Module.runMain (module.js:605:10)
    at run (bootstrap_node.js:420:7)
    at startup (bootstrap_node.js:139:9)
Trace
    at factorial (/Users/lucasfcosta/factorial.js:4:13)
    at Object.<anonymous> (/Users/lucasfcosta/factorial.js:12:1)
    at Module._compile (module.js:571:32)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:488:32)
    at tryModuleLoad (module.js:447:12)
    at Function.Module._load (module.js:439:3)
    at Module.runMain (module.js:605:10)
    at run (bootstrap_node.js:420:7)
    at startup (bootstrap_node.js:139:9)
Trace
    at factorial (/Users/lucasfcosta/factorial.js:4:13)
    at Object.<anonymous> (/Users/lucasfcosta/factorial.js:12:1)
    at Module._compile (module.js:571:32)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:488:32)
    at tryModuleLoad (module.js:447:12)
    at Function.Module._load (module.js:439:3)
    at Module.runMain (module.js:605:10)
    at run (bootstrap_node.js:420:7)
    at startup (bootstrap_node.js:139:9)

如你所见,我们并没有同时跟踪多个factorial ,因为每次调用它,我们就不再需要之前的帧了。

创建尾部递归的一个好建议是把下一次调用所需要"状态"全部传递过去,如此便能把下一个帧丢掉。由于你不能总是在单个函数中执行此操作,你可以考虑创建嵌套函数的可行性,可以是尾部递归

同时你也应该记住PTC不一定使你的代码运行的更快,事实上,大多数时候只会让它更慢


然而,除了允许你使用更少的内存来存储栈,当使用PTC你可以获取本地分配的对象,最终也使得需要更少的内存来运行递归,这是因为既然你下一次的递归不需要当前帧的任何变量,你就允许了垃圾回收机制把当前帧每一个分配的对象回收,而‘非尾部调用’函数在每次递归被调用时候就需要执行分配,基于这个情况,所有帧都将被保存进栈直到最后一个递归调用返回(即“基本案例”)。


尾部调用优化(TCO)

异于PTC,尾部调用优化(TCO)事实上提高了尾部递归的性能并且使他运行得更快.

TCO是一项通过编译器使用jumps去转换你的递归成循环的技术

既然我们直到了尾部递归的工作原理,如何解释TCO的工作原理就变得很简单

让我们用之前示例用的factorial 函数模拟在JavaScript引擎启用TCO之后会发生什么.

这是开始时的代码:

function factorial(n, total = 1) {
    if (n === 0) {
        return total
    }

    return factorial(n - 1, n * total)
}

考虑到这里的代码会一直重复直到满足退出条件(“基本案例”),我们可以把它放在一个标签内然后直接跳过它而不用去再次调用函数,所以我们的代码会变成类似这样:

function factorial(n, total = 1) {
    LABEL:
        if (n === 0) {
            return total
        }
        total = n * total
        n = n - 1
        goto LABEL
}

这意味着TCO与PTC不一样

使用PTC与TCO的缺点

正如在上面例子中看到的,PTC意味着不会将所有的函数“保存”到栈,这使得通过读取堆栈跟踪更难定位bug,因为我们没有所有导致当前情况的调用信息。

这也影响了console.trace声明及Error.stack属性

一个可行的解决的方案是在开发环境有一个“Shadow Stack”

“Shadow Stack”就如同“第二个栈”,当正常的栈在PTC被调用时没有保持帧,就把该帧推入“Shadow Stack”,这样我们就能用他来定位问题又避免了把帧推入执行堆栈中。

然而,正如你想的那样,缺少综合好用的工具,它就需要更多的内存来存储所有的帧(这在开发环境也许不会是个问题)

最后,如果使用TCO,使用"shadow stack"仍然没有解决Error.stack属性的问题。因为当我们开始使用goto声明语句时并没有添加任何帧到堆栈跟踪。这意味着error发生时,它可能没有栈内的函数。因为我们是通过跳转标签而不是通过正常调用函数得到这个声明。

如果对此感到好奇,可以查看这篇

Syntactic Tail Calls (STC)

STC是在需要PTC和TCO时向编译器指示的一种指示方式

通过这个方式可以让开发者去选择是否想要这个特性。基本是一个明确的选择。

这使我们能够管理缓存堆栈框架的复杂性,并为“较少侵入性的解决方案(或根本没有解决方案)”提供新的可能性(如提案本身所说)。

当谈到语法时,有一些替代方案正在研究中,可以通过这里查看最新资讯。

相关材料

编辑于 2017-05-18

文章被以下专栏收录

    坐标上海,专注前端,基于Node全栈开发,欢迎加入我们!【https://ctripfe.github.io/】