詳解JavaScript調(diào)用棧、尾遞歸和手動(dòng)優(yōu)化
調(diào)用棧(Call Stack)
調(diào)用棧(Call Stack)是一個(gè)基本的計(jì)算機(jī)概念,這里引入一個(gè)概念:棧幀。
棧幀是指為一個(gè)函數(shù)調(diào)用單獨(dú)分配的那部分??臻g。
當(dāng)運(yùn)行的程序從當(dāng)前函數(shù)調(diào)用另外一個(gè)函數(shù)時(shí),就會(huì)為下一個(gè)函數(shù)建立一個(gè)新的棧幀,并且進(jìn)入這個(gè)棧幀,這個(gè)棧幀稱為當(dāng)前幀。而原來(lái)的函數(shù)也有一個(gè)對(duì)應(yīng)的棧幀,被稱為調(diào)用幀。每一個(gè)棧幀里面都會(huì)存入當(dāng)前函數(shù)的局部變量。
當(dāng)函數(shù)被調(diào)用時(shí),就會(huì)被加入到調(diào)用棧頂部,執(zhí)行結(jié)束之后,就會(huì)從調(diào)用棧頂部移除該函數(shù)。并將程序運(yùn)行權(quán)利(幀指針)交給此時(shí)棧頂?shù)臈?。這種后進(jìn)后出的結(jié)構(gòu)也就是函數(shù)的調(diào)用棧。
而在JavaScript里,可以很方便的通過(guò)console.trace()這個(gè)方法查看當(dāng)前函數(shù)的調(diào)用幀
尾調(diào)用
說(shuō)尾遞歸之前必須先了解一下什么是尾調(diào)用。簡(jiǎn)單的說(shuō),就是一個(gè)函數(shù)執(zhí)行的最后一步是將另外一個(gè)函數(shù)調(diào)用并返回。
以下是正確示范:
// 尾調(diào)用正確示范1.0 function f(x){ return g(x); } // 尾調(diào)用正確示范2.0 function f(x) { if (x > 0) { return m(x) } return n(x); }
1.0程序的最后一步即是執(zhí)行函數(shù)g,同時(shí)將其返回值返回。2.0中,尾調(diào)用并不是非得寫在最后一行中,只要執(zhí)行時(shí),是最后一步操作就可以了。
以下是錯(cuò)誤示范:
// 尾調(diào)用錯(cuò)誤示范1.0 function f(x){ let y = g(x); return y; } // 尾調(diào)用錯(cuò)誤示范2.0 function f(x){ return g(x) + 1; } // 尾調(diào)用錯(cuò)誤示范3.0 function f(x) { g(x); // 這一步相當(dāng)于g(x) return undefined }
1.0最后一步為賦值操作,2.0最后一步為加法運(yùn)算操作,3.0隱式的有一句return undefined
尾調(diào)用優(yōu)化
在調(diào)用棧的部分我們知道,當(dāng)一個(gè)函數(shù)A調(diào)用另外一個(gè)函數(shù)B時(shí),就會(huì)形成棧幀,在調(diào)用棧內(nèi)同時(shí)存在調(diào)用幀A和當(dāng)前幀B,這是因?yàn)楫?dāng)函數(shù)B執(zhí)行完成后,還需要將執(zhí)行權(quán)返回A,那么函數(shù)A內(nèi)部的變量,調(diào)用函數(shù)B的位置等信息都必須保存在調(diào)用幀A中。不然,當(dāng)函數(shù)B執(zhí)行完繼續(xù)執(zhí)行函數(shù)A時(shí),就會(huì)亂套。
那么現(xiàn)在,我們將函數(shù)B放到了函數(shù)A的最后一步調(diào)用(即尾調(diào)用),那還有必要保留函數(shù)A的棧幀么?當(dāng)然不用,因?yàn)橹蟛⒉粫?huì)再用到其調(diào)用位置、內(nèi)部變量。因此直接用函數(shù)B的棧幀取代A的棧幀即可。當(dāng)然,如果內(nèi)層函數(shù)使用了外層函數(shù)的變量,那么就仍然需要保留函數(shù)A的棧幀,典型例子即是閉包。
在網(wǎng)上有很多關(guān)于講解尾調(diào)用的博客文章,其中流傳廣泛的一篇中有這樣一段。我不是很認(rèn)同。
function f() { let m = 1; let n = 2; return g(m + n); } f(); // 等同于 function f() { return g(3); } f(); // 等同于 g(3);
以下為博客原文:上面代碼中,如果函數(shù)g不是尾調(diào)用,函數(shù)f就需要保存內(nèi)部變量m和n的值、g的調(diào)用位置等信息。但由于調(diào)用g之后,函數(shù)f就結(jié)束了,所以執(zhí)行到最后一步,完全可以刪除 f() 的調(diào)用記錄,只保留 g(3) 的調(diào)用記錄。
但我認(rèn)為第一種中,也是先執(zhí)行m+n這步操作,再調(diào)用函數(shù)g同時(shí)返回。這應(yīng)當(dāng)是一次尾調(diào)用。同時(shí)m+n的值也通過(guò)參數(shù)傳入函數(shù)g內(nèi)部,并沒(méi)有直接引用,因此也不能說(shuō)需要保存f內(nèi)部的變量的值。
總得來(lái)說(shuō),如果所有函數(shù)的調(diào)用都是尾調(diào)用,那么調(diào)用棧的長(zhǎng)度就會(huì)小很多,這樣需要占用的內(nèi)存也會(huì)大大減少。這就是尾調(diào)用優(yōu)化的含義。
尾遞歸
遞歸,是指在函數(shù)的定義中使用函數(shù)自身的一種方法。函數(shù)調(diào)用自身即稱為遞歸,那么函數(shù)在尾調(diào)用自身,即稱為尾遞歸。
最常見(jiàn)的遞歸,斐波拉契數(shù)列,普通遞歸的寫法:
function f(n) { if (n === 0 || n === 1) return n else return f(n - 1) + f(n - 2) }
這種寫法,簡(jiǎn)單粗暴,但是有個(gè)很嚴(yán)重的問(wèn)題。調(diào)用棧隨著n的增加而線性增加,當(dāng)n為一個(gè)大數(shù)(我測(cè)了一下,當(dāng)n為100的時(shí)候,瀏覽器窗口就會(huì)卡死。。)時(shí),就會(huì)爆棧了(棧溢出,stack overflow)。這是因?yàn)檫@種遞歸操作中,同時(shí)保存了大量的棧幀,調(diào)用棧非常長(zhǎng),消耗了巨大的內(nèi)存。
接下來(lái),將普通遞歸升級(jí)為尾遞歸看看。
function fTail(n, a = 0, b = 1) { if (n === 0) return a return fTail(n - 1, b, a + b) }
很明顯,其調(diào)用棧為
fTail(5) => fTail(4, 1, 1) => fTail(3, 1, 2) => fTail(2, 2, 3) => fTail(1, 3, 5) => fTail(0, 5, 8) => return 5
被尾遞歸改寫之后的調(diào)用棧永遠(yuǎn)都是更新當(dāng)前的棧幀而已,這樣就完全避免了爆棧的危險(xiǎn)。
但是,想法是好的,從尾調(diào)用優(yōu)化到尾遞歸優(yōu)化的出發(fā)點(diǎn)也沒(méi)錯(cuò)),讓我們看看V8引擎官方團(tuán)隊(duì)的解釋
Proper tail calls have been implemented but not yet shipped given that a change to the feature is currently under discussion at TC39.
意思就是人家已經(jīng)做好了,但是就是還不能不給你用:)嗨呀,好氣喔。
當(dāng)然,人家肯定是有他的正當(dāng)理由的:
- 在引擎層面消除尾遞歸是一個(gè)隱式的行為,程序員寫代碼時(shí)可能意識(shí)不到自己寫了死循環(huán)的尾遞歸,而出現(xiàn)死循環(huán)后又不會(huì)報(bào)出stack overflow的錯(cuò)誤,難以辨別。
- 堆棧信息會(huì)在優(yōu)化的過(guò)程中丟失,開(kāi)發(fā)者調(diào)試非常困難。
道理我都懂,但是不信邪的我拿nodeJs(v6.9.5)手動(dòng)測(cè)試了一下:
好的,我服了
手動(dòng)優(yōu)化
雖然我們暫時(shí)用不上ES6的尾遞歸高端優(yōu)化,但遞歸優(yōu)化的本質(zhì)還是為了減少調(diào)用棧,避免內(nèi)存占用過(guò)多,爆棧的危險(xiǎn)。而俗話說(shuō)的好,一切能用遞歸寫的函數(shù),都能用循環(huán)寫——尼克拉斯·夏,如果將遞歸改成循環(huán)的話,不就解決了這種調(diào)用棧的問(wèn)題么。
方案一:直接改函數(shù)內(nèi)部,循環(huán)執(zhí)行
function fLoop(n, a = 0, b = 1) { while (n--) { [a, b] = [b, a + b] } return a }
這種方案簡(jiǎn)單粗暴,缺點(diǎn)就是沒(méi)有遞歸的那種寫法比較容易理解。
方案二:Trampolining(蹦床函數(shù))
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
這種寫法算是容易理解一些了,就是蹦床函數(shù)的作用需要仔細(xì)看看。缺點(diǎn)還有就是需要修改原函數(shù)內(nèi)部的寫法。
方案三:尾遞歸函數(shù)轉(zhuǎn)循環(huán)方法
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
經(jīng)過(guò) tailCallOptimize 包裝后返回的是一個(gè)新函數(shù) accumulator,執(zhí)行 f時(shí)實(shí)際執(zhí)行的是這個(gè)函數(shù)。這種方法可以不用修改原遞歸函數(shù),當(dāng)調(diào)用遞歸時(shí)只用使用該方法轉(zhuǎn)置一下便可解決遞歸調(diào)用的問(wèn)題。
總結(jié)
尾遞歸優(yōu)化是個(gè)好東西,但既然暫時(shí)用不上,那我們就該在平時(shí)編碼的過(guò)程中,對(duì)使用到了遞歸的地方特別敏感,時(shí)刻避免出現(xiàn)死循環(huán),爆棧等危險(xiǎn)。畢竟,好的工具不如好的習(xí)慣。
以上就是本文的全部?jī)?nèi)容,希望對(duì)大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
javascript實(shí)現(xiàn)簡(jiǎn)單查找與替換的方法
這篇文章主要介紹了javascript實(shí)現(xiàn)簡(jiǎn)單查找與替換的方法,涉及javascript針對(duì)頁(yè)面查找與替換的相關(guān)技巧,具有一定參考借鑒價(jià)值,需要的朋友可以參考下2015-07-07JavaScript中removeChild 方法開(kāi)發(fā)示例代碼
這篇文章主要介紹了JavaScript中removeChild 方法開(kāi)發(fā)示例代碼,非常不錯(cuò),具有參考借鑒價(jià)值,需要的朋友可以參考下2016-08-08js運(yùn)動(dòng)動(dòng)畫(huà)的八個(gè)知識(shí)點(diǎn)
本文給大家分享的是個(gè)人在學(xué)習(xí)js運(yùn)動(dòng)動(dòng)畫(huà)的過(guò)程中總結(jié)的我們需要注意的8個(gè)知識(shí)點(diǎn),分別是速度動(dòng)畫(huà)、透明度漸變、緩沖運(yùn)動(dòng)、多物體運(yùn)動(dòng)、獲取樣式、任意屬性值、鏈?zhǔn)竭\(yùn)動(dòng)和同時(shí)運(yùn)動(dòng),非常的詳細(xì),推薦給小伙伴們。2015-03-03Bootstrap打造一個(gè)左側(cè)折疊菜單的系統(tǒng)模板(二)
這篇文章主要介紹了Bootstrap打造一個(gè)左側(cè)折疊菜單的系統(tǒng)模板(二)的相關(guān)資料,需要的朋友可以參考下2016-05-05基于d3.js/neovis.js/neod3.js實(shí)現(xiàn)鏈接neo4j圖形數(shù)據(jù)庫(kù)的圖像化顯示功能
neovis.js?由vis.js支持的圖形可視化以及來(lái)自Neo4j的數(shù)據(jù)。這篇文章主要介紹了基于d3.js/neovis.js/neod3.js實(shí)現(xiàn)鏈接neo4j圖形數(shù)據(jù)庫(kù)的圖像化顯示功能,需要的朋友可以參考下2022-02-02webpack打包優(yōu)化的幾個(gè)方法總結(jié)
這篇文章主要介紹了webpack打包優(yōu)化的幾個(gè)方法總結(jié),文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2020-02-02JS如何判斷瀏覽器類型和詳細(xì)區(qū)分IE各版本瀏覽器
本篇文章主要介紹了JS判斷瀏覽器類型和詳細(xì)區(qū)分IE各版本瀏覽器的代碼,非常具有實(shí)用價(jià)值,有興趣的可以了解一下。2017-03-03