JavaScript 尾递归调用优化实质解释
JavaScript 尾递归调用优化实质解释
( ** 感谢评论区的网友指出问题,该文章 this 部分存在问题,我仍然保留原本,算是提供一种思考方式,请自助取舍(主要是不想改了,O(∩_∩)O哈哈~)。 ** )
我是在写一段代码的时候,打算用 ...rest
参数承接多参数情况,为了熟悉就去翻一下 ES6 的教程(《ES6入门》),顺带着把那一章节所讲的复习一下,然后看到了尾递归优化,说其实质是把递归转为了循环,我突然想到我接下的代码会用到递归,会不会有栈满的情况(我碰到过),所以想着就干脆把它吃下来看看。
注意:
深究这段代码前请进行取舍,JS 尾调用并不是一定会用到,而且有时候递归就能解决很多问题,我是为了了解一下它设计的思想,看以后如果我不能用递归的时候该怎么进行设计。此处摘出一位网友的说法:
尾归调用的想法是好的,但是落地的时候出现了分歧,node在后续的版本中支持过尾归调用,但后续给去掉了。浏览器上只有safari支持,而其他浏览器上并不支持。所以,这是一个“未真正实现的提议”,大家仅仅了解下,目前还无法普遍用到生产环境中。”
吐槽一下
代码很精简,精简的难以看懂,gou ri de,花了我好久…………
正文分隔符
文前语:部分文字和代码来自于 阮一峰的《ES6入门》,以及一个“关于尾递归优化的问题”论坛的网友们。
设计精髓
在讲递归优化之前,先说出我理解到的其中设计精髓所在,先读一遍,然后再去研究具体实现的代码,那样会更容易。
- 利用
this
的arguments
把下一轮递归参数给带出。 - 递归时只有第一次调用了,递归在真正运转,后面每一次递归内返回的函数结果都是
undefined
,也就是没有递归了。
递归函数
下面是一个正常的递归函数。
function sum(x, y) {
if (y > 0) return sum(x + 1, y - 1);
else return x;
}
sum(1, 100000)
// Uncaught RangeError: Maximum call stack size exceeded(…)
上面代码中,sum
是一个递归函数,参数x
是需要累加的值,参数y
控制递归次数。一旦指定sum
递归 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)
这是 ES6教程 原文:
“上面代码中,tco
函数是尾递归优化的实现,它的奥妙就在于状态变量active
。默认情况下,这个变量是不激活的。一旦进入尾递归优化的过程,这个变量就激活了。然后,每一轮递归sum
返回的都是undefined
,所以就避免了递归执行;而accumulated
数组存放每一轮sum
执行的参数,总是有值的,这就保证了accumulator
函数内部的while
循环总是会执行。这样就很巧妙地将“递归”改成了“循环”,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。”
上面这一段话看着是不是有些懵,没事,往下看,我也没打算让你直接就读懂,反正我是没读懂,不过他说的都没错,等你了解了之后再来看这段话就能读懂了。
了解过程
这段过程的替换代码来自于“论坛”中一位网友的提供,这样可以更直观的感受到函数运行的流程,使我在理解上向前走了一步。
我们来看递归函数本身:
var sum = tco(function(x, y) {
if (y > 0) {
return sum(x + 1, y - 1)
} else {
return x
}
});
tco
返回的是一个函数,我们也将之进行等价替换。且这个函数内部有三个私有变量:value
,active
,accumulated
。(记住,这三个变量是属于私有变量。为了便于理解,我们将第一次调用的这个toc
称之为first-toc
)
var sum = function() {
accumulated.push(arguments);
if (!active) {
active = true;
while (accumulated.length) {
value = function(x, y) {
if (y > 0) {
return sum(x + 1, y - 1)
} else {
return x
}
}.apply(this, accumulated.shift());
}
active = false;
return value;
}
};
接下来我们分析一下 sum(1, 100000)
实质发生了什么?
- 首先,把输入的两个参数从
arguments
提取出来给了accumulated
进行存储。
accumulated = [ {0:1, 1:100000} ];
// 等价于
// first-toc.accumulated = [ {0:1, 1:100000} ]
- 然后进入
if
语句,
active = true;
// 等价于
// first-toc.active = true
- 然后进入
while
语句,
value = function(x, y) {
if (y > 0) return sum(x + 1, y - 1)
else return x;
}.apply(this, accumulated.shift());
注意:这里的这个 this 很重要!
apply
可以改变函数的运行环境,即运行时函数内部this
变量的值。这时,我们调用this
,就把first-toc
传送给了function
内部,成了this
调用的背景,如下:
value = function(x, y) {
if (y > 0) return sum(x + 1, y - 1)
else return x;
// this.accumulated 等价于 first-toc.accumulated ;
}.apply(this, accumulated.shift());
那么这个转移有什么用呢?我们继续往下看。
(如果你不理解 apply
的具体作用,参考这篇文章。)
- 进入
while
语句后可以等价替换成
value = (function(x, y) { if (y > 0) { return sum(x + 1, y - 1); } else { return x } }(1, 100000));
// 这是一个立即执行函数
- 又可以等价替换成
value = sum(1+1, 100000 - 1)
// return sum(x + 1, y - 1)
- 此时(我们还在第一次执行中噢),当前的(
first-toc
)私有变量值的情况:
accumulated = [] ;
value = undefined ;
active = true ;
还能记得这三个值怎么来的吗?
accumulated
:在apply(this, accumulated.shift())
时被清空了,shift
函数的作用是 删除数组的第一个元素并返回 。
value
:value
不是等于sum(1+1, 100000 - 1)
吗?怎么变成 undefined
了?我们继续往下看。
active
:它是在进入while
循环之前就被修改了。active = true; while (accumulated.length) ……
- 我们继续走
刚才我们已经得到了:
value = sum(1+1, 100000 - 1);
还记得我们最开始的替换吗? sum
等于什么?
tco
返回的是一个函数,我们也将之进行等价替换。且这个函数内部有三个私有变量:value
,active
,accumulated
。(记住,这三个变量是属于私有变量。)
var sum = function() {
accumulated.push(arguments);
if (!active) { // 你觉得此时的 active 是 ture 还是 false ?
active = true;
while (accumulated.length) {
value = function(x, y) {
if (y > 0) {
return sum(x + 1, y - 1)
} else {
return x
}
}.apply(this, accumulated.shift());
}
active = false;
return value;
}
};
这是第二次递归入口,我称这个变量为 second-toc
,请你尝试回答一下我在代码中的提问,你肯定会想翻一下 toc
函数的定义,我给你贴在了下面:
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;
}
};
}
你觉得是 false
?你有没有感觉我忘了讲什么?要不要往上翻翻?
还记得我让你的注意的那个this
吗?
那么这个转移有什么用呢?
现在你能猜出second-toc.action
的值了吗?
答案是 true
,理由嘛自己想去。不过,this
可不只这么点作用。
我们运行第 2 次那个递归:
value = sum(1+1, 100000 - 1);
var sum = function() {
accumulated.push(arguments);
if (!active) { // active == true ;
active = true;
while (accumulated.length) {
value = function(x, y) {
if (y > 0) {
return sum(x + 1, y - 1)
} else {
return x
}
}.apply(this, accumulated.shift());
}
active = false;
return value;
}
// else return undefined; // 原函数没有 else ,程序执行完成默认返回 undefined 。
};
因为active == true
,所以第二次递归没有进入到while
循环里,而是直接执行完成,所以返回了undefined
。所以:
value = sum(1+1, 100000 - 1);
执行结果为 value = undefined ;
。
- 那么接下来呢?
我们再返回第一次递归执行:
var sum = function() {
accumulated.push(arguments);
if (!active) {
active = true;
while (accumulated.length) {
value = function(x, y) {
if (y > 0) {
return sum(x + 1, y - 1)
} else {
return x
}
}.apply(this, accumulated.shift());
// 我们刚才执行到这儿
// 上一步得到结果 value = undefined
}
active = false;
return value;
}
};
value
都等于undefined
了,accumulated
也因为shift()
被清空了,那程序岂不是结束了?只执行了一遍?
肯定不对呀!
怎么能只执行一遍呢,递归那么深呢,都把栈压满了!其实你忽略了一些东西,给你提示一下: this
的作用是替换函数背景域!!
决定性的一句
回去看看第二次递归的代码(其实看toc
的函数定义就行),是不是有这么一句:
accumulated.push(arguments);// 这个变量是不是看着有些眼熟?
现在你能猜出来了吗?再给你点提示:
while (accumulated.length) {
value = function(x, y) {
if (y > 0) {
return sum(x + 1, y - 1)
} else {
return x
}
}.apply(this, accumulated.shift());
// 等价于
// value = (function(x, y) { if (y > 0) { return sum(x + 1, y - 1); } else { return x } }(1, 100000));
}
答案揭晓:(1, 100000)
作为参数被写到函数内部的 arguments
里了,然后因为apply
和this
的关系:
first-toc.accumulated == second-toc.accumulated
所以第一次递归里:
while (accumulated.length) == while (first-toc.accumulated.length) == while (second-toc.accumulated.length)
判断结果就为true
啦!如此往复循环,却不会爆栈。
你想到了吗?
意义及局限
我把这一部分放在了最后,也是我后来才思考出来的:披着递归的外衣,干着循环的事情! (在写代码时用递归的逻辑去写,代码结构清晰易懂,但你把 toc 函数利用起来后,实际执行是通过循环执行,也就是说完全不用担心爆栈的问题啦!)。
局限性也很明显,首先必须是尾递归调用;其次,这个是 JavaScript 的代码,我尚未想出如何用其他代码实现,譬如 Java / C++ ,如果有实际用到再考虑研究吧。