深入理解JavaScript中的尾调用(Tail Call)


Posted in Javascript onFebruary 07, 2017

什么是尾调用?

尾调用是函数式编程里比较重要的一个概念,尾调用的概念非常简单,一句话就能说清楚,它的意思是在函数的执行过程中,如果最后一个动作是一个函数的调用,即这个调用的返回值被当前函数直接返回,则称为尾调用,如下所示:

function f(x) { 
 return g(x)
}

在 f 函数中,最后一步操作是调用 g 函数,并且调用 g 函数的返回值被 f 函数直接返回,这就是尾调用。

而下面两种情况就不是尾调用:

// 情况一
function f(x){
 let y = g(x);
 return y;
}

// 情况二
function f(x){
 return g(x) + 1;
}

上面代码中,情况一是调用函数g之后,还有别的操作,所以不属于尾调用,即使语义完全一样。情况二也属于调用后还有操作,即使写在一行内。。

为什么说尾调用重要呢,原因是它不会在调用栈上增加新的堆栈帧,而是直接更新调用栈,调用栈所占空间始终是常量,节省了内存,避免了爆栈的可能性。用上面的栗子来说,尾调用的调用栈是这样的:

[f(x)] => [g(x)]

由于进入下一个函数调用时,前一个函数内部的局部变量(如果有的话)都不需要了,那么调用栈的长度不会增加,可以直接跳入被尾调用的函数。如果是非尾调用的情况下,调用栈会长这样:

[f(x)] => [1 + g(x)]

可以看到,调用栈的长度增加了一位,原因是 f 函数中的常量 1 必需保持保持在调用栈中,等待 g 函数调用返回后才能被计算回收。如果 g 函数内部还调用了函数 h 的话,就需要等待 h 函数返回,以此类推,调用栈会越来越长。如果这样解释还不够直观的话,尾调用还有一种特殊情况叫做尾递归,它的应用更广,看起来也更直观。

尾递归

顾名思义,在一个尾调用中,如果函数最后的尾调用位置上是这个函数本身,则被称为尾递归。递归很常用,但如果没写好的话也会非常消耗内存,导致爆栈。一般解释递归会用阶乘或者是斐波那契数列求和作为示例,这里用后者来解释一下。Fibonacci 数列就不多做解释了,它是一个长这样的无限长的数列,从第三项开始,每项都是前两项的和:

0, 1, 1, 2, 3, 5, 8, 13, 21, ...

如果要计算第 n 项(从第 0 项开始)的值的话,写成递归是常用的手段。如果是非尾递归的形式,可以写成这样:

function fibonacci(n) { 
 if (n === 0) return 0
 if (n === 1) return 1
 return fibonacci(n - 1) + fibonacci(n - 2)
}

以 n = 5 来说,fibonacci 函数的调用栈会像这样展开:

[fibonacci(5)]
[fibonacci(4) + fibonacci(3)]
[(fibonacci(3) + fibonacci(2)) + (fibonacci(2) + fibonacci(1))]
[((fibonacci(2) + fibonacci(1)) + (fibonacci(1) + fibonacci(0))) + ((fibonacci(1) + fibonacci(0)) + fibonacci(1))]
[fibonacci(1) + fibonacci(0) + fibonacci(1) + fibonacci(1) + fibonacci(0) + fibonacci(1) + fibonacci(0) + fibonacci(1)]
[1 + 0 + 1 + 1 + 0 + 1 + 0 + 1]
5

才到第 5 项调用栈长度就有 8 了,一些复杂点的递归稍不注意就会超出限度,同时也会消耗大量内存。而如果用尾递归的方式来优化这个过程,就可以避免这个问题,用尾递归来求 Fibonacci 数列的值可以写成这样:

function fibonacciTail(n, a = 0, b = 1) { 
 if (n === 0) return a
 return fibonacciTail(n - 1, b, a + b)
}

在这里,每次调用后递归传入 fibonacciTail 函数的 n 会依次递减 1,它实际上是用来记录递归剩余的次数。而 a 和 b 两个参数在每次递归时也会在计算后再次传入 fibonacciTail 函数,写成调用栈的形式就很清楚了:

fibonacciTail(5) === fibonacciTail(5, 0, 1) 
fibonacciTail(4, 1, 1) 
fibonacciTail(3, 1, 2) 
fibonacciTail(2, 2, 3) 
fibonacciTail(1, 3, 5) 
fibonacciTail(0, 5, 8) => return 5

可以看到,每次递归都不会增加调用栈的长度,只是更新当前的堆栈帧而已。也就避免了内存的浪费和爆栈的危险。

注意

很多介绍尾调用和尾递归的文章讲到这里就结束了,实际上情况并非这么简单,尾调用在没有进行任何优化的时候和其他的递归方式一样,该产生的调用栈一样会产生,一样会有爆栈的危险。而尾递归之所以可以优化,是因为每次递归调用的时候,当前作用域中的局部变量都没有用了,不需要层层增加调用栈再在最后层层回收,当前的调用帧可以直接丢弃了,这才是尾调用可以优化的原因。

由于尾递归是尾调用的一种特殊形式,相对简单一些,在 ES6 没有开启尾调用优化的时候,我们可以手动为尾递归做一些优化。

尾递归优化

改写为循环

之所以需要优化,是因为调用栈过多,那么只要避免了函数内部的递归调用就可以解决掉这个问题,其中一个方法是用循环代替递归。还是以 Fibonacci 数列举例:

function fibonacciLoop(n, a = 0, b = 1) { 
 while (n--) {
 [a, b] = [b, a + b]
 }
 return a
}

这样,不存在函数的多次调用,将递归转变为循环,避免了调用栈的无限增加。

蹦床函数

另一个优化方法是借助一个蹦床函数的帮助,它的原理是接受一个函数作为参数,在蹦床函数内部执行函数,如果函数的返回是也是一个函数,就继续执行。

function trampoline(f) { 
 while (f && f instanceof Function) {
 f = f()
 }
 return f
}

可以看到,这里也没有在函数内部调用函数,而是在循环中重复调用同一个函数,这也避免了增加调用栈长度,下面要做的是将原来的 Fibonacci 函数改写为每次返回另一个函数的版本:

function fibonacciFunc(n, a = 0, b = 1) { 
 if (n > 0) {
 [a, b] = [b, a + b]
 return fibonacciFunc.bind(null, n - 1, a, b)
 } else {
 return a
 }
}

trampoline(fibonacciFunc(5)) // return 5

实际的尾递归优化

实际上,真正的尾递归优化并非像上面一样,上面的两种方法实际上都改写了尾递归函数本身,而真正的尾递归优化应该是非入侵式的,下面是尾递归优化的一种实现:

function tailCallOptimize(f) { 
 let value,
 active = false
 const accumulated = []
 return function accumulator() {
 accumulated.push(arguments)
 if (!active) {
 active = true
 while (accumulated.length) {
 value = f.apply(this, accumulated.shift())
 }
 active = false
 return value
 }
 }
}

然后将原来的 fibonacciTail 函数传入 tailCallOptimize 函数,得到一个新函数,这个新函数的执行过程就是经过尾递归优化的了:

const fibonacciTail = tailCallOptimize(function(n, a = 0, b = 1) { 
 if (n === 0) return a
 return fibonacciTail(n - 1, b, a + b)
})
fibonacciTail(5) // return 5

下面解释一下这种优化方式的原理。

1. 首先通过闭包,在 tailCallOptimize 的作用域中保存唯一的 active 和 accumulated,其中 active 指示尾递归优化过程是否开始,accumulated 用来存放每次递归调用的参数,push 方法将参数入列,shift 方法将参数出列,保证先进先出顺序执行。

2. 经过 tailCallOptimize 包装后返回的是一个新函数 accumulator,执行 fibonacciTail 时实际执行的是这个函数,第一次执行时,现将 arguments0 推入队列,active 会被标记为 true,然后进入 while 循环,取出 arguments0。在 while 循环的执行中,会将参数类数组 arguments1 推入 accumulated 队列,然后直接返回 undefined,不会递归调用增加调用栈。

3. 随后 while 循环会发现 accumulated 中又多了一个 arguments1,然后再将 arguments2 推入队列。这样,在 while 循环中对 accumulated 的操作就是放进去一个、拿出来一个、再放进去一个、再拿出来一个,以此类推。

4. 最后一次 while 循环返回的就是尾递归的结果了。

问题

实际上,现在的尾递归优化在引擎实现层面上还是有问题的。拿 V8 引擎来说,尾递归优化虽然已经实现了,但默认是不开启的,V8 团队还是更倾向于用显式的语法来优化。原因是在他们看来,尾调用优化仍然存在一些问题,主要有两点:

难以辨别

在引擎层面消除尾递归是一个隐式行为,函数是不是符合尾调用的要求,可能程序员在写代码的时候不会意识到,另外由于开启了尾调用优化,一旦出现了死循环尾递归,又不会引发溢出,难以辨别。下面介绍一些识别尾调用要注意的地方:

首先,调用函数的方式不重要,以下几种调用方式只要出现在尾调用位置上都可以被优化: + 普通调用:func(...) + 作为方法调用:obj.method(...) + 使用 call 或 apply 调用:func.call(..) func.apply(...)

表达式中的尾调用

ES6 的箭头函数可以使用一个表达式作为自己的函数体,函数返回值就是这个表达式的返回值,在表达式中,以下几种情况可能包含尾调用:

三元运算符(? :)

const a = x => x ? f() : g()

在这里,f 和 g 函数都在尾调用位置上。为了便于理解,可以将函数改写一下:

const a = x => { 
 if (x) {
 return f()
 } else {
 return g()
 }
}

可见 f 和 g 的返回值都是直接被返回的,符合尾调用的定义。

逻辑运算符(|| 与 &&)

首先是 || 运算符:

const a = () => f() || g()

这里 f 函数不在尾递归位置上,而 g 函数在尾递归位置上,为什么,把函数改写一下就清楚了:

const a = () => { 
 const result = f()
 if (result) {
 return result
 } else {
 return g()
 }
}

|| 运算符的结果依赖于 f 函数的返回值,而不是直接返回 f 的返回值,直接返回的只有 g 函数的返回值。&& 运算符的情况也同理:

const a = () => f() && g()

将函数改写为:

const a = () => { 
 const result = f()
 if (!result) {
 return result
 } else {
 return g()
 }
}

说明 f 函数也不在尾递归位置上,而 g 函数在尾递归位置上。

逗号运算符(,)

const a = () => (f(), g())

将函数改写一下:

const a = () => { 
 f()
 return g()
}

可见,在尾递归位置上的仍然只有一个 g 函数。

语句中的尾调用

在 JS 语句中,以下几种情况可能包含尾调用: + 代码块中(由 {} 分隔的语句) + if 语句的 then 或 else 块中 + do-while,while,for 循环的循环体中 + switch 语句的执行代码块中 + try-catch 语句的 catch 块中 + try-finally,try-catch-finally 语句的 finally 块中

此外,return 语句也可以包含尾调用,如果 return 的表达式包含尾调用,return 语句就包含尾调用,这就不用多解释了。

单独的函数调用不是尾调用

下面这个函数是否包含尾调用呢:

function foo() { 
 bar()
}

答案是否定的,还是先将函数改写一下:

function foo() { 
 bar()
 return undefined
}

可以看到 return 语句返回的只是一个 undefined 而并非 bar 函数的返回值,所以这里不存在尾调用。

尾调用只能出现在严格模式中

在非严格模式中,大多数引擎会在函数上增加下面两个属性: + func.arguments 包含调用函数时传入的参数 + func.caller 返回当前函数的调用者

但一旦进行了尾调用优化,中间调用帧会被丢弃,这两个属性也就失去了本来的意义,这也是在严格模式中不允许使用这两个属性的原因。

堆栈信息丢失

除了开发者难以辨别尾调用以外,另一个原因则是堆栈信息会在优化的过程中丢失,这对于调试是不方便的,另外一些依赖于堆栈错误信息来进行用户信息收集分析的工具可能会失效。针对这个问题,实现一个影子堆栈可以解决堆栈信息缺失的问题,但这中解决方式相当于对堆栈进行了模拟,不能保证始终符合实际虚拟机堆栈的真实状态。另外,影子堆栈的性能开销也是非常大的。

基于以上原因,V8 团队建议使用特殊的语法来指定尾递归优化,TC39 标准委员会有一个还没有结论的提案叫做从语法上指定尾部调行为,这个提案由来自 Mozilla 和微软的委员提出。提案的具体内容可以看链接,主要是提出了三种手动指定尾调用优化的语法。

附手动优化语法

Return Continue

function factorial(n, acc = 1) { 
 if (n === 1) {
 return acc;
 }

 return continue factorial(n - 1, acc * n)
}

let factorial = (n, acc = 1) => continue 
 n == 1 ? acc
 : factorial(n - 1, acc * n);

// or, if continue is an expression form:
let factorial = (n, acc = 1) => 
 n == 1 ? acc
 : continue factorial(n - 1, acc * n);

Function sigil

// # sigil, though it's already 'claimed' by private state.
#function() { /* all calls in tail position are tail calls */ }

// Note that it's hard to decide how to readably sigil arrow functions.

// This is probably most readable.
() #=> expr
// This is probably most in line with the non-arrow sigil.
#() => expr

// rec sigil similar to async functions
rec function() { /* likewise */ } 
rec () => expr

!-return

function () { !return expr }

// It's a little tricky to do arrow functions in this method.
// Obviously, we cannot push the ! into the expression, and even
// function level sigils are pretty ugly.

// Since ! already has a strong meaning, it's hard to read this as
// a tail recursive function, rather than an expression.
!() => expr

// We could do like we did for # above, but it also reads strangely:
() !=> expr

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作能带来一定的帮助,如果有疑问大家可以留言交流。

Javascript 相关文章推荐
解析js中获得父窗口链接getParent方法以及各种打开窗口的方法
Jun 19 Javascript
jQuery学习笔记之jQuery动画效果
Sep 09 Javascript
jquery实现定时自动轮播特效
Dec 10 Javascript
js 实现数值的千分位及保存小数方法(推荐)
Aug 01 Javascript
jquery做个日期选择适用于手机端示例
Jan 10 Javascript
彻底学会Angular.js中的transclusion
Mar 12 Javascript
vuejs实现本地数据的筛选分页功能思路详解
Nov 15 Javascript
详解如何用typescript开发koa2的二三事
Nov 13 Javascript
通过实例了解js函数中参数的传递
Jun 15 Javascript
使用npm命令提示: 'npm' 不是内部或外部命令,也不是可运行的程序的处理方法
May 14 Javascript
vue单文件组件无法获取$refs的问题
Jun 24 Javascript
nuxt 页面路由配置,主页轮播组件开发操作
Nov 05 Javascript
基于JavaScript实现下拉列表左右移动代码
Feb 07 #Javascript
原生JavaScript实现AJAX、JSONP
Feb 07 #Javascript
[原创]SyntaxHighlighter自动识别并加载脚本语言
Feb 07 #Javascript
javascript表达式和运算符详解
Feb 07 #Javascript
利用jQuery实现滑动开关按钮效果(附demo源码下载)
Feb 07 #Javascript
原生js和css实现图片轮播效果
Feb 07 #Javascript
bootstrap输入框组使用方法
Feb 07 #Javascript
You might like
php定时计划任务的实现方法详解
2013/06/06 PHP
PHP校验15位和18位身份证号的类封装
2018/11/07 PHP
Firebug 字幕文件JSON地址获取代码
2009/10/28 Javascript
再谈javascript 动态添加样式规则 W3C校检
2009/12/25 Javascript
jquery animate图片模向滑动示例代码
2011/01/26 Javascript
JavaScript Scoping and Hoisting 翻译
2012/07/03 Javascript
js原型链原理看图说明
2012/07/07 Javascript
jQuery学习笔记(4)--Jquery中获取table中某列值的具体思路
2013/04/10 Javascript
Angularjs中$http以post请求通过消息体传递参数的实现方法
2016/08/05 Javascript
js+div+css下拉导航菜单完整代码分享
2016/12/28 Javascript
使用base64对图片的二进制进行编码并用ajax进行显示
2017/01/03 Javascript
JS jQuery使用正则表达式去空字符的简单实现代码
2017/05/20 jQuery
JS检测window.open打开的窗口是否关闭
2017/06/25 Javascript
bootstrap datepicker插件默认英文修改为中文
2017/07/28 Javascript
Node.js Express安装与使用教程
2018/05/11 Javascript
Vue 设置axios请求格式为form-data的操作步骤
2019/10/29 Javascript
[02:56]DOTA2亚洲邀请赛 VG出场战队巡礼
2015/02/07 DOTA
Python中使用Boolean操作符做真值测试实例
2015/01/30 Python
python检测某个变量是否有定义的方法
2015/05/20 Python
使用Python内置的模块与函数进行不同进制的数的转换
2016/03/12 Python
python操作字典类型的常用方法(推荐)
2016/05/16 Python
selenium + python 获取table数据的示例讲解
2018/10/13 Python
对python中if语句的真假判断实例详解
2019/02/18 Python
人工神经网络算法知识点总结
2019/06/11 Python
Flask-WTF表单的使用方法
2019/07/12 Python
python+Django实现防止SQL注入的办法
2019/10/31 Python
Python hashlib常见摘要算法详解
2020/01/13 Python
Django Admin后台模型列表页面如何添加自定义操作按钮
2020/11/11 Python
使用Python通过oBIX协议访问Niagara数据的示例
2020/12/04 Python
科沃斯机器人官网商城:Ecovacs
2016/08/29 全球购物
教师党性分析材料
2014/02/04 职场文书
团队经理竞聘书
2014/03/31 职场文书
农业开发项目建议书
2014/05/16 职场文书
社区母亲节活动总结
2015/02/10 职场文书
幼师个人总结范文
2015/02/28 职场文书
导游词之云南省玉龙雪山
2019/12/19 职场文书