递归函数尾调用优化

什么是递归函数?

递归函数就是函数在内部调用自身。

潜在的问题

递归函数是非常消耗内存的。如果一个递归函数不进行尾调用优化,如果调用次数特很大,就会发生”栈溢出”。对于浏览器中的表现就是浏览器直接卡死。这个时候如果不杀掉浏览器进程,电脑配置不行的话,甚至你会发现你的电脑会开始嗷嗷叫。

解决

但是有些时候递归函数确实很方便,用它可以达到4两拨千金的效果。首先我弄明白了为什么递归函数消耗内存,原因就是每次调用自身之后,会一直将调用记录保存到内存中。如果有1成千上万个记录,就超过浏览器内存。因此解决办法就是:让内存中只保持一个调用记录。这就是递归函数的尾调用优化。

1
2
3
4
5
6
function sum(n) {
if (n === 1) return 1;
return n + sum(n - 1);
}

sum(10) // 55

这是个递归函数,计算1-10累加的值 ,会保存10个调用记录,复杂度O(10)
但是如果改写成尾递归,只保留一个调用记录,复杂度就是O(1) 。

1
2
3
4
5
6
function sum(n,total) {
if (n === 1) return total;
return sum(n - 1,n + total);
}

sum(10,1) // 55

看上去没多大区别,但是尾调用之后,直接返回了调用结果。可以关闭这个调用,进行下一个。而第一种则是调用结果跟n相加,因为n这个变量,后面需要用到,这就类似闭包了。因为n需要保存到内存中供后续继续使用,导致这个调用也跟着保存下来。
由此可见,”尾调用优化”对递归函数很重要,有一些函数式编程语言将其写入了语言规格。ES6也是如此,第一次明确规定,所有 ECMAScript 的实现,都必须部署”尾调用优化”。这就是说,在 ES6 中,只要使用尾递归,就不会发生栈溢出,相对节省内存。

上面的尾递归优化是最基本的。事实上函数式编程有一个概念,叫做柯里化(currying),意思是将多参数的函数转换成单参数的形式。这里可以使用柯里化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function currying(fn, n) {
return function (m) {
return fn.call(this, m, n);
};
}

function tailSum(n, total) {
if (n === 1) return total;
return tailSum(n - 1, n + total);
}

const sum = currying(tailSum, 1);

sum(10) // 55

通过柯里化,将尾递归函数 tailSum 变为只接受1个参数的 sum 。
使用ES6就会简单很多:

1
2
3
4
5
function sum(n, total = 1) {
if (n === 1) return total;
return sum(n - 1, n + total);
}
sum(10) // 55 (ES6的写法,参数可以有默认值,可以不传total)

严格模式限制

ES6的尾调用优化只在严格模式下生效,正常模式下是无效的。
这是因为在正常模式下,函数内部有arguments参数,导致默认参数值无效,但是严格模式下是没有arguments的。

总结

作为一名js程序员,不能只看到眼前的代码和逻辑,还应该懂得背后的架构与原理。
离这一点还差的很遥远,继续探索中。。。。