詳解JavaScript調用棧、尾遞歸和手動優化

調用棧(Call Stack)html

調用棧(Call Stack)是一個基本的計算機概念,這裏引入一個概念:棧幀。node

棧幀是指爲一個函數調用單獨分配的那部分棧空間。程序員

當運行的程序從當前函數調用另一個函數時,就會爲下一個函數創建一個新的棧幀,而且進入這個棧幀,這個棧幀稱爲當前幀。而原來的函數也有一個對應的棧幀,被稱爲調用幀。每個棧幀裏面都會存入當前函數的局部變量。算法

 

當函數被調用時,就會被加入到調用棧頂部,執行結束以後,就會從調用棧頂部移除該函數。並將程序運行權利(幀指針)交給此時棧頂的棧幀。這種後進後出的結構也就是函數的調用棧。數組

而在JavaScript裏,能夠很方便的經過console.trace()這個方法查看當前函數的調用幀瀏覽器

 

尾調用數據結構

說尾遞歸以前必須先了解一下什麼是尾調用。簡單的說,就是一個函數執行的最後一步是將另一個函數調用並返回。閉包

如下是正確示範:app

// 尾調用正確示範1.0
function f(x){
return g(x);
}
// 尾調用正確示範2.0
function f(x) {
if (x > 0) {
return m(x)
}
return n(x);
}

1.0程序的最後一步便是執行函數g,同時將其返回值返回。2.0中,尾調用並非非得寫在最後一行中,只要執行時,是最後一步操做就能夠了。函數

如下是錯誤示範:

// 尾調用錯誤示範1.0
function f(x){
let y = g(x);
return y;
}
// 尾調用錯誤示範2.0
function f(x){
return g(x) + 1;
}
// 尾調用錯誤示範3.0
function f(x) {
g(x); // 這一步至關於g(x) return undefined
}

1.0最後一步爲賦值操做,2.0最後一步爲加法運算操做,3.0隱式的有一句return undefined

尾調用優化

在調用棧的部分咱們知道,當一個函數A調用另一個函數B時,就會造成棧幀,在調用棧內同時存在調用幀A和當前幀B,這是由於當函數B執行完成後,還須要將執行權返回A,那麼函數A內部的變量,調用函數B的位置等信息都必須保存在調用幀A中。否則,當函數B執行完繼續執行函數A時,就會亂套。

那麼如今,咱們將函數B放到了函數A的最後一步調用(即尾調用),那還有必要保留函數A的棧幀麼?固然不用,由於以後並不會再用到其調用位置、內部變量。所以直接用函數B的棧幀取代A的棧幀便可。固然,若是內層函數使用了外層函數的變量,那麼就仍然須要保留函數A的棧幀,典型例子便是閉包。

在網上有不少關於講解尾調用的博客文章,其中流傳普遍的一篇中有這樣一段。我不是很認同。

function f() {
let m = 1;
let n = 2;
return g(m + n);
}
f();
// 等同於
function f() {
return g(3);
}
f();
// 等同於
g(3);

如下爲博客原文:上面代碼中,若是函數g不是尾調用,函數f就須要保存內部變量m和n的值、g的調用位置等信息。但因爲調用g以後,函數f就結束了,因此執行到最後一步,徹底能夠刪除 f() 的調用記錄,只保留 g(3) 的調用記錄。

但我認爲第一種中,也是先執行m+n這步操做,再調用函數g同時返回。這應當是一次尾調用。同時m+n的值也經過參數傳入函數g內部,並無直接引用,所以也不能說須要保存f內部的變量的值。

總得來講,若是全部函數的調用都是尾調用,那麼調用棧的長度就會小不少,這樣須要佔用的內存也會大大減小。這就是尾調用優化的含義。

尾遞歸

遞歸,是指在函數的定義中使用函數自身的一種方法。函數調用自身即稱爲遞歸,那麼函數在尾調用自身,即稱爲尾遞歸。

最多見的遞歸,斐波拉契數列,普通遞歸的寫法:

function f(n) {
if (n === 0 || n === 1) return n
else return f(n - 1) + f(n - 2)
}

這種寫法,簡單粗暴,可是有個很嚴重的問題。調用棧隨着n的增長而線性增長,當n爲一個大數(我測了一下,當n爲100的時候,瀏覽器窗口就會卡死。。)時,就會爆棧了(棧溢出,stack overflow)。這是由於這種遞歸操做中,同時保存了大量的棧幀,調用棧很是長,消耗了巨大的內存。

接下來,將普通遞歸升級爲尾遞歸看看。

function fTail(n, a = 0, b = 1) {
if (n === 0) return a
return fTail(n - 1, b, a + b)
}

很明顯,其調用棧爲

 

複製代碼 代碼以下: 
fTail(5) => fTail(4, 1, 1) => fTail(3, 1, 2) => fTail(2, 2, 3) => fTail(1, 3, 5) => fTail(0, 5, 8) => return 5

被尾遞歸改寫以後的調用棧永遠都是更新當前的棧幀而已,這樣就徹底避免了爆棧的危險。

可是,想法是好的,從尾調用優化到尾遞歸優化的出發點也沒錯,然並卵:),讓咱們看看V8引擎官方團隊的解釋

Proper tail calls have been implemented but not yet shipped given that a change to the feature is currently under discussion at TC39.

意思就是人家已經作好了,可是就是還不能不給你用:)嗨呀,好氣喔。

固然,人家確定是有他的正當理由的:

  1. 在引擎層面消除尾遞歸是一個隱式的行爲,程序員寫代碼時可能意識不到本身寫了死循環的尾遞歸,而出現死循環後又不會報出stack overflow的錯誤,難以辨別。
  2. 堆棧信息會在優化的過程當中丟失,開發者調試很是困難。

道理我都懂,可是不信邪的我拿nodeJs(v6.9.5)手動測試了一下:

好的,我服了

手動優化

雖然咱們暫時用不上ES6的尾遞歸高端優化,但遞歸優化的本質仍是爲了減小調用棧,避免內存佔用過多,爆棧的危險。而俗話說的好,一切能用遞歸寫的函數,都能用循環寫――尼克拉斯・夏,若是將遞歸改爲循環的話,不就解決了這種調用棧的問題麼。

方案一:直接改函數內部,循環執行

function fLoop(n, a = 0, b = 1) {
while (n--) {
[a, b] = [b, a + b]
}
return a
}

這種方案簡單粗暴,缺點就是沒有遞歸的那種寫法比較容易理解。

方案二:Trampolining(蹦牀函數)

function trampoline(f) {
while (f && f instanceof Function) {
f = f()
}
return f
}
function f(n, a = 0, b = 1) {
if (n > 0) {
[a, b] = [b, a + b]
return f.bind(null, n - 1, a, b)
} else {
return a
}
}
trampoline(f(5)) // return 5

這種寫法算是容易理解一些了,就是蹦牀函數的做用須要仔細看看。缺點還有就是須要修改原函數內部的寫法。

方案三:尾遞歸函數轉循環方法

function tailCallOptimize(f) {
let value
let active = false
const accumulated = []
return function accumulator() {
accumulated.push(arguments)
if (!active) {
active = true
while (accumulated.length) {
value = f.apply(this, accumulated.shift())
}
active = false
return value
}
}
}
const f = tailCallOptimize(function(n, a = 0, b = 1) {
if (n === 0) return a
return f(n - 1, b, a + b)
})
f(5) // return 5

通過 tailCallOptimize 包裝後返回的是一個新函數 accumulator,執行 f時實際執行的是這個函數。這種方法能夠不用修改原遞歸函數,當調用遞歸時只用使用該方法轉置一下即可解決遞歸調用的問題。

總結

尾遞歸優化是個好東西,但既然暫時用不上,那咱們就該在平時編碼的過程當中,對使用到了遞歸的地方特別敏感,時刻避免出現死循環,爆棧等危險。畢竟,好的工具不如好的習慣。

以上就是本文的所有內容,但願對你們的學習有所幫助,也但願你們多多支持腳本之家。

您可能感興趣的文章:

文章同步發佈: https://www.geek-share.com/detail/2707475570.html

相關文章
相關標籤/搜索