尾调用是函数式编程的一个重要概念,本身非常简单,一句话就是某个函数的最后一步是调用另一个函数(仅仅调用第一个函数,不用有任何其他操作,否则不属于尾递归)
1.尾递归不一定出现在函数尾部,只要是最后一步操作即可
function f(x) { if(x>0){ return m(x); } return n(x); }
上面的代码中,函数m和n都属于尾调用,因为他们都是函数f的最后一步操作。
2.递归帧和尾递归的优化
每一个我们进行递归,就会有一个"递归帧"压如栈空间,如A函数递归调用B函数,那么A函数的递归帧会压栈,直到B函数释放栈空间,所以递归是一个十分耗费内存的操作,如斐波那契数列
function fib() { if(n===1 || n===2){ return 1; } return f(n-1)+f(n-2); } fib(10) // 55 fib(100) // 栈满
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置,内部变量等信息不会再使用了,直接用内函数的调用帧取代外层函数即可,上代码
function fib(n,ca1=1,ca2=1){ if(n===1 || n===2){ return ca2; } return fib(n-1,ca2,ca1+ca2); } console.log(fib(100)); //354224848179262000000
这就是尾递归的优化,每次执行时栈内只有一个帧,将大大的节省内存,这就是尾递归的调用和优化。
“尾递归优化"对递归操作意义重大,所以一些函数式编程语言将其写入了规格,es6就是如此,ECMAScript的实现都必须实现尾递归优化。
3.递归函数的改写和柯里化(currying)
尾递归的实现往往需要改写递归函数,确保最后一步只调用自身。做到这一点,就要把所有用到的内部变量改写成函数的参数,上面已经可以看到,这么做的缺点是不太直观,有了两个方法可以解决这个问题。
方法一,柯里化概念,就是将多参数的函数转化成为单参数的形式,其实加上其他函数,柯里化函数只处理第一个函数,其他的函数处理其他参数,举例如下
function currying(fn,n) { return function (m) { return fn.call(this,m,n); } } function tailFactorial(n,total) { if(n===1) return total; return tailFactorial(n-1,n*total); } const factorial = currying(tailFactorial,1); console.log(factorial(5));
将尾递归函数tailFactorial变为接受1个参数的factorial
方法二,使用es6的函数默认值
function factorial(n,total=1) { if(n===1) return total; return factorial(n-1,total*n); } factorial(5)//120
因为默认值给了1,所以调用时不用提供这个值。
4.严格模式
es6的尾调用优化,只在严格模式下开启,正常模式下是无效的,这是因为,正常模式下函数内部有两个变量,可以跟踪函数的调用栈
func.arguments:返回调用函数的参数(arguments.callee 得到函数) func.caller: 返回调用当前函数的那个函数。
function fib(n,ca1=1,ca2=1){ if(n===1 || n===2){ return ca2; } console.log(fib.caller); console.log(fib.arguments); return fib(n-1,ca2,ca1+ca2); } console.log(fib(5)); [Function] { '0': 5 } [Function: fib] { '0': 4, '1': 1, '2': 2 } [Function: fib] { '0': 3, '1': 2, '2': 3 } 5
可以看得到调用的参数越来越多。
5.自己实现尾递归的优化
我们如何在正常环境下,进行尾递归的优化呢?原理非常简单,尾递归之所以需要优化,就是调用太多造成栈溢出,那就要减少调用栈,用循环代替递归。
参考文献:
阮一峰:es6标准入门(第三版)