尾调用是函数式编程中一个很重要的概念,当一个函数执行时的最后一个步骤是返回另一个函数的调用,这就叫做尾调用。
注意这里函数的调用方式是无所谓的,以下方式均可:
并且只有下列表达式会包含尾调用:
依次举例:
函数在调用的时候会在调用栈(call stack)中存有记录,每一条记录叫做一个调用帧(call frame),每调用一个函数,就向栈中push一条记录,函数执行结束后依次向外弹出,直到清空调用栈,参考下图:
造成这种结果是因为每个函数在调用另一个函数的时候,并没有 return 该调用,所以JS引擎会认为你还没有执行完,会保留你的调用帧。
baz() 里面调用了 bar() 函数,并没有 return 该调用,所以在调用栈中保持自己的调用帧,同时 bar() 函数的调用帧在调用栈中生成,同理,bar() 函数又调用了 foo() 函数,最后执行到 foo() 函数的时候,没有再调用其他函数,这里没有显示声明 return,所以这里默认 return undefined。
foo() 执行完了,销毁调用栈中自己的记录,依次销毁 bar() 和 baz() 的调用帧,最后完成整个流程。
如果对上面的例子做如下修改:
这里要注意:尾调用优化只在严格模式下有效。
在非严格模式下,大多数引擎会包含下面两个属性,以便开发者检查调用栈:
在尾调用优化中,这些属性不再有用,因为相关的信息可能以及被移除了。因此,严格模式(strict mode)禁止这些属性,并且尾调用优化只在严格模式下有效。
如果尾调用优化生效,流程图就会变成这样:
我们可以很清楚的看到,尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用记录,只要直接用内层函数的调用记录取代外层函数的调用记录就可以了,调用栈中始终只保持了一条调用帧。
这就叫做尾调用优化,如果所有的函数都是尾调用的话,那么在调用栈中的调用帧始终只有一条,这样会节省很大一部分的内存,这也是尾调用优化的意义。
先来看一下递归,当一个函数调用自身,就叫做递归。
上面这个操作就叫做递归,但是注意了,这里没有结束条件,是死递归,所以会报栈溢出错误的,写代码时千万注意给递归添加结束条件。
那么什么是尾递归? 前面我们知道了尾调用的概念,当一个函数尾调用自身,就叫做尾递归。
那么尾递归相比递归而言,有哪些不同呢? 我们通过下面这个求阶乘的例子来看一下:
上面是使用递归来计算阶乘的例子,操作系统为JS引擎调用栈分配的内存是有大小限制的,如果计算的数字足够大,超出了内存最大范围,就会出现栈溢出错误。
这里500000并不是临界值,只是我用了一个足够造成栈溢出的数。
如果用尾递归来计算阶乘呢?
通过尾递归,我们把复杂度从O(n)降低到了O(1),如果数据足够大的话,会节省很多的计算时间。 由此可见,尾调用优化对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。 要做到这一点,需要把函数内部所有用到的中间变量改写为函数的参数,就像上面的factorial()函数改写一样。
这样做的缺点就是语义不明显,要计算阶乘的函数,为什么还要另外传入一个参数叫total? 解决这个问题的办法有两个:
上面这种写法其实有点类似于做了一个函数柯里化,但不完全符合柯里化的概念。 函数柯里化是指把接受多个参数的函数转换为接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下参数且返回结果的新函数。
概念看着很绕口,我们来个例子感受一下:
可以看到,柯里化函数通过闭包找到父作用域里的变量,最后依次相加输出结果。 通过这个例子,可能看不出为什么要用柯里化,有什么好处,这个我们以后再谈,这里先引出一个概念。
是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。
如果用柯里化改写求阶乘的例子:
这是符合柯里化概念的写法,在阮一峰老师的文章中是这样写的:
我个人认为,这种写法其实不是柯里化,因为并没有将多参数的tailFacrotial改写为接受单参数的形式,只是换了一种写法,和下面这样写意义是一样的:
这篇文章我们主要讨论了尾调用优化和柯里化。 要注意的是,经过测试,Chrome和Firefox并没有对尾调用进行优化,Safari对尾调用进行了优化。 Node高版本也已经去除了通过--harmony_tailcalls参数启用尾调用优化。