尾调用
尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,即指某个函数的最后一步调用另一个函数。
function f(x){
return g(x);
}
上述代码中,函数的最后一步是调用函数g,这就叫尾调用。
以下三种情况,都不属于尾调用。
function f(x){
let y = g(x);
return y;
}
function f(x){
`return g(x) + 1;
}
function f(x){
g(x);
}
上面代码中,第一个调用函数g后还有赋值操作。第二种也是有后续的操作。而第三个则等同于
function f(x){
g(x);
return undefined;
}
尾调用之所以与其他调用不同,就在于它的特殊的调用位置。
我们知道,函数的调用会在内存姓曾一个调用记录,又称为call frame,保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的call frame的上方还会形成一个B的call frame。等B运行结束,将结果返回A,B的call frame才会消失。如果函数B内部还调用C,那么还有一个C的call frame,以此类推,所有的call frame 就形成了一个call stack。
尾调用优化
尾调用由于是函数的最后一步操作,如果调用位置,内部变量等信息都不会再用到了。尾调用时可以删除此call frame下方的call frame。这就叫做尾调用优化(Tail call optimization)。
当然,有些尾调用还会用到内部变量如:
function addOne(a){
var one = 1;
function inner(b){
return b + one;
}
return inner(a);
}
这时候就无法进行尾调用优化。
尾递归
函数调用自身,称为递归。如果尾调用自身,就称为尾递归。
递归非常的耗费内存,因为需要同时保存成百上千个call frame。很容易发生栈溢出错误。对于尾调用优化后的尾递归来说,因为只有一个call frame。所以不会存在栈溢出的问题。
如下面阶乘计算递归:
function factorail(n){
if(n === 1) return 1;
return n*factorial(n-1);
}
这种情况下降无法进行尾调用优化。所以这里的空间复杂度为O(n)。
而如果改写成尾递归并进行优化,则空间复杂度为O(1),像下面这样
function factorial(n, total){
if( n===1 ) return total;
return factorail(n-1, n*total);
}
尾递归改写
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用刀的内部变量改写成函数的参数。像上面的阶乘函数改写一样。这样做的缺点是函数不再直观,或者说会更改函数的调用形式。
有两种方法可以解决这个问题:
一是进行进一步的封装,并提供默认值
function tailFactorial(n, total) {
if (n === 1) return total;
return tailFactorial(n - 1, n * total);
}
function factorial(n) {
return tailFactorial(n, 1);
}
二是使用(柯理化)currying。意思是将多参数的函数转换成单参数的函数的形式。
function curring(fn, n) {
return function (m) {
return fn.call(this, m, n);
}
}
function tailFactorail(n, total) {
if (n === 1) return total;
return tailFactorail(n - 1, n * total);
}
const factorial = curring(tailFactorail, 1);
factorial(5);
这种方法的原理与第一种类似。只是使用了较为规范的封装。
尾调用优化
前面一直提到尾调用优化,那么尾调用优化是怎么实现的呢。优化的目标就是减少调用栈,普通尾调用优化实现不清楚。下面阐述尾递归函数的优化,尾递归优化的策略就是用循环替换掉递归:
如下面这个递归函数:
function sum(x, y) {
if (y > 0) {
return sum(x + 1, y - 1);
} else {
return x;
}
}
sum(1, 100000)
上述函数将会产生100000个call frame,通常情况下回报栈溢出异常或错误。
蹦床函数
可以使用蹦床函数(trampoline)将递归转化为循环
function trampoline(f){
while(f && f instanceof Function ){
f = f();
}
return f;
}
上面就是蹦床函数的实现,可见其中的参数f必须在执行之后返回一个同形式的函数。
所以我们需要将递归函数改写成这样:
function sum(x, y) {
if (y > 0) {
return sum.bind(null, x + 1, y - 1);
} else {
return x;
}
}
即使用bind将一个函数形式绑定到sum变量上并返回。以下是bind的作用
bind()方法会创建一个新函数,当这个新函数被调用时,它的this值是传递给bind()的第一个参数, 它的参数是bind()的其他参数和其原本的参数.
上述代码中,sum函数每次执行,都会返回自身的另一个版本(新函数)。像下面这样调用,就不会发生栈溢出
trampoline(sum(1, 100000))
真正的优化策略
然而,蹦床函数并不是真正的尾递归的优化, 下面的实现才是。
function tco(f) {
var value;
var active = false;
var accumulated = [];
return function accumulator() {
accumulated.push(arguments);
if (!active) {
active = true;
while (accumulated.length) {
value = f.apply(this, accumulated.shift());
}
active = false;
return value;
}
};
}
var sum = tco(function(x, y) {
if (y > 0) {
return sum(x + 1, y - 1)
}
else {
return x
}
});
sum(1, 100000)
以上实现中,每当调用sum
时,实际上会调用tco
函数,并执行其中的return
语句,此语句将执行accumulator
函数。这个函数的精妙之处一个是在于,他将原本每次递归调用时使用的参数保存在accumulated
中,而每次执行sum
操作时都会使用正确的参数。另一个是使用active
标志来表示是否进入尾调用优化,第一次调用时进入优化过程后会屏蔽if
下面的代码。这就导致在每次执行f.apply(this, accumulated.shift())
时只会将正确的参数push
到accumulated
变量中,不会产生循环调用。这样的效果是f.apply(this, accumulated.shift())
执行sum
函数中的x+1,y-1
操作,也就是递归的核心变化程序,然后将参数保存在accumulated
变量中,并返回一个undefined
给value
。之后第一层调用会判断参数是否存在,如果存在继续执行调用。最终,在sum
返回一个结果之而非函数时,accumulated
将为空,则跳出循环并得到结果。由于每一次执行f.apply
函数只会产生三个call frame就会返回,继而再次调用执行, 所以不会造成栈溢出情况。