Week11 - 让递迴的Stack永远不会爆炸的「尾递迴」真的有那么神奇吗 - 尾递迴篇 [高智能方程式系列]

本文章同时发布于:

MediumiT邦帮忙

大家好,最近因为有一位朋友提到尾递迴,说这个优化技术「可以让递迴跑个一百万次都没问题」,惊呆的我,就花了些时间研究他,于是就产生了这个系列。

这个「高智能方程式」系列会以介绍以下为主:

演算法资料结构效能优化

而为什么叫做高智能方程式,主要是因这是我童年很喜欢看的闪电霹雳车的香港名称,希望各位的code能够因为这些文章讨论而像阿斯拉一样越跑越快!绝对不是因为这部动画刚好有程式两个字的关係(`∀´)

先说结论

其实他并不是很高深的技术,此优化就是:

将符合尾递迴形式的递迴转换成迭代

什么是尾递迴

尾递迴就是递迴的事物只能为函数自己

以程式码来说,如果我们有一个数列[1, 2, 3, 4, 5],我们要把他们全部相加,以尾递迴的写法就是:

function recursive(n, ans = 0) {    if (n === 0) return ans    return recursive(n - 1, ans + n) // 回传的事物只有函数自己}

如果你是以下的写法,就不算是尾递迴:

function recursive(n) {    if (n === 0) return n    return n + recursive(n - 1) // 回传的事物除了函数自己还有n}

除此之外,如果像这样recursive(n - 1) + recursive(n - 1)回传两个自己也不行,

而当我们「正确的使用尾递迴」,我们就称为PTC(Proper Tail Calls)。

以费氏数列来举例,尾递迴帮助了我们什么

大家应该都还是得费氏数列的公式:

间单来说就是:

第一个数字为0,第二个数字为1,除了前两者,下一个数字都为前两个数字的和

费氏数列的前六个数为[0, 1, 1, 2, 3, 5](第零个也是一个数),
我们现在要算出第六个数为多少,在程式上面非常直观的实作就是:

function fibonacci(n) {  if (n === 0) return 0;  if (n === 1) return 1;  return fibonacci(n - 1) + fibonacci(n - 2)}console.log(fibonacci(5))// 得到5

而在执行的时候我们的Stack(记忆体),到底发生了什么事呢?我们可以在程式码里面加入debugger

function fibonacci(n) {  debugger  if (n === 0) return 0;  if (n === 1) return 1;  return fibonacci(n - 1) + fibonacci(n - 2)}console.log(fibonacci(5))// 得到5

在Chrome浏览器上执行后,Javascript就会停在debugger的地方,

这时候我们可以点选左方的箭头让Javascript继续递迴,这时你会看到右下角Call Stack的视窗产生了很多个fibonacci,那是因为:

在递迴未完成时,任何因递迴函数产生的Stack都不会被释放掉

所以整体执行会像下图:

当每次递迴时,都会再产生新的函数,而新的函数又会再产生新的函数,直到n === 0n === 1的时候才不会产生新的函数,而是回传数值,当函数回传数值的时候,此函数才结束所有的任务,而释放Stack。

这样的做法会有两种问题:

Stack因为递迴佔着空间而消耗太大许多的重複的函数,例如fibonacci(1)就被呼叫了第五次

所以如果你执行这个递迴1000000次,就会爆炸:

function fibonacci(n) {  if (n === 0) return 0;  if (n === 1) return 1;  return fibonacci(n - 1) + fibonacci(n - 2)}console.log(fibonacci(1000000))

这时候如果我们启用尾递迴,他就会正常,我先贴上最后的答案:

function trampolines (fn) {  return (...args) => {    let result = fn(...args)    while (typeof result === 'function') result = result()    return result  }}function fibonacci(n, ans = [0, 1]) {  if (n === 0) return ans[0]  if (n === 1) return ans[1]  if (n <= 2) return ans[ans.length - 1] + ans[ans.length - 2]  ans.push(ans[ans.length - 1] + ans[ans.length - 2])  return () => fibonacci(n - 1, ans);}console.log(trampolines(fibonacci)(1000000))// 虽然会显示Infinity,但这主要是因为数字太大了Javascript不支援,但是是可以运行的

这倒是怎么运作的呢?我依序以下来分析:

迭代版尾递迴版配合trampolines来弥补Chrome没有尾递迴优化功能的尾递迴版

首先,先以迭代的方式思考

我们可以通过数列的方式来思考,如果我们要取第六个数,可以列出以下数列

[0, 1, 1, 2, 3, 5]

第零个与第一个数是我们已知的,所以我们可以先预设好,剩下的数字每个值都是前两个直相加,所以我们可以写成以下程式码:

function fibonacci(n) {  let ans = [0, 1]  if (n === 0) return ans[0]  if (n === 1) return ans[1]  for(let i = 2; i <= n; i++) {    ans.push(ans[ans.length - 1] + ans[ans.length - 2])  }  return ans[ans.length - 1];}console.log(fibonacci(5))

可以发现,其实这样的迭代已经与尾递迴的写法相同了,他们关键的差异就是:

迭代是宣告ans为一个区域变数,然后每次迭代都改变它尾递迴的写法是透过参数将ans带入下一次的递迴

将迭代改成尾递迴版

把以上迭代的程式改为递迴形式,并将ans以参数带入下次递迴就会变成如下:

function fibonacci(n, ans = [0, 1]) {  if (n === 0) return ans[0]  if (n === 1) return ans[1]  if (n <= 2) return ans[ans.length - 1] + ans[ans.length - 2]  ans.push(ans[ans.length - 1] + ans[ans.length - 2])  return fibonacci(n - 1, ans);}console.log(fibonacci(5))

而这时候,因为改为了PTC的形式,在以往个Chrome就会启用TCO(Tail Call Optimization)优化机制来释放Stack,会用「正规的尾递迴优化」,

但是现在Chrome不会启用TCO机制了

为什么呢?至于原因稍后会说明。

使用trampolines达到TCO的尾递迴版

既然现在Chrome没有TCO优化机制,要如何释放掉Stack,必须我们自己来实作,这时候我们会採用一个叫做trampolines的一个函数,他的作用就是:

将递迴转换成迭代

为了配合trampolines,我们需要将尾递迴做一点手脚,改为闭包,

为什么要这样呢?我们一行一行分析:

function trampolines (fn) { // 读取一开始的函数  return (...args) => { // 将函数的参数读取    let result = fn(...args) // 第一次执行函数,如果获得闭包函数,即进入while迴圈    while (typeof result === 'function') result = result() // 当每次执行函数时,ans的值都透过闭包保存,而因为已经回传了闭包,所以函数执行完毕,即释放Stack    return result // 迭代result至不再回传闭包函数而是数值时,回传数值  }}function fibonacci(n, ans = [0, 1]) {  if (n === 0) return ans[0]  if (n === 1) return ans[1]  if (n <= 2) return ans[ans.length - 1] + ans[ans.length - 2]  ans.push(ans[ans.length - 1] + ans[ans.length - 2])  return () => fibonacci(n - 1, ans) // 如果回传不改为闭包,在let result = fn(...args)时就会一次执行完}console.log(trampolines(fibonacci)(5))

使用闭包可以让递迴不一次执行完,并透过trampolines来执行且释放Stack,其关键如下:

一次执行完会导致:「每次回传都是再呼叫一个函数」,所以在「最后回传数值」前都没有任何一个函数执行完毕,而Stack也就没办法释放。回传闭包会导致:「每次回传都是一个闭包」,所以「每次都让函数执行完毕」,而Stack就会跟着被释放,不用等到「最后开始回传值」才被释放。

Chrome原本不需要trampolines的帮忙

前面有提到Chrome一开始是支援的自动TCO的,所以我们在写以下函数写成PTC时:

function fibonacci(n, ans = [0, 1]) {  if (n === 0) return ans[0]  if (n === 1) return ans[1]  if (n <= 2) return ans[ans.length - 1] + ans[ans.length - 2]  ans.push(ans[ans.length - 1] + ans[ans.length - 2])  return fibonacci(n - 1, ans)}console.log(fibonacci(5))

就会直接有trampolines配合闭包的效果,那为什么Chrome后来拿掉了呢?

大家可以看看Chrome引擎V8的讨论串,讨论串主要是认为,大多数的Javascript开发者,可能对「尾递迴」不了解,甚至不知道有这个东西,如果TCO机制只要发现PTC时就直接启动,那大多数人遇到的问题将会是:

侦错时没办法依照Stack慢慢去看递迴哪边出问题了,因为Stack早就被释放了,可是大多数开发者不知道有这机制

你可能会说:那如果开发者可以「明确的说我要尾递迴」再启动TCO不就好了嘛?

是的,所以TC39有加入了STC(Syntactic Tail Calls)的proposal,只要在递迴时加入continue,就是明确的跟引擎说:「请帮我启动TCO来优化尾递迴」,範例如下:

function factorial(n, acc = 1) {  if (n === 1) {    return acc;  }  return continue factorial(n - 1, acc * n)}

但...此proposal后来没有被导入,然后,就没有然后惹(ノД`)


如果想要体验「正规的尾递迴优化」,目前唯一有支援TCO功能的只有Safari,在启动严谨模式(strict mode)会自动执行TCO,程式码如下:

'use strict';function fibonacci(n, ans = [0, 1]) {  if (n === 0) return ans[0]  if (n === 1) return ans[1]  if (n <= 2) return ans[ans.length - 1] + ans[ans.length - 2]  ans.push(ans[ans.length - 1] + ans[ans.length - 2])  return fibonacci(n - 1, ans)}console.log(fibonacci(1000000))// 虽然会显示Infinity,但这主要是因为数字太大了Javascript不支援,但是是可以运行的

最后,我们需要尾递迴吗?

事实上,以「达到目标」来说,我们用尾递迴达到的效果,都可以透过迭代达到,因为尾递迴本质就是把递迴转为迭代,所以当你有天发现你在撰写的递迴,可以使用尾递迴时,那其实就是告诉你目前的函数可以转为迭代。

所以要选择尾递迴还是用迭代呢?我认为这与你目前撰写的区块风格有关,

如果你现在这个区块的风格多是FP的宣告式风格(Declarative),那可以採用尾递迴,
如果多是指令式风格(Imperative),那就可以採用迭代~


谢谢你的阅读,欢迎指教与讨论~

参考资料

Trampolines in JavaScriptGood Morning, Functional JS (Day 28, Trampolines) - iT 邦帮忙::一起帮忙解决难题,拯救 IT 人的一天Recursion optimization in JS - where is it? PTC, TCO and FUD - DEV Community ?‍??‍?Fibonacci sequence in Javascript什么是尾递归?

关于作者: 网站小编

码农网专注IT技术教程资源分享平台,学习资源下载网站,58码农网包含计算机技术、网站程序源码下载、编程技术论坛、互联网资源下载等产品服务,提供原创、优质、完整内容的专业码农交流分享平台。

热门文章