本文2433字,閱讀大約須要10分鐘。css
總括: 本文介紹了尾調用,尾遞歸的概念,結合實例解釋了什麼是尾調用優化,並闡述了尾調用優化現在的現狀。html
事親以敬,美過三牲。前端
尾調用是函數式編程的一個重要的概念,本篇文章就來學習下尾調用相關的知識。node
在以前的文章理解Javascript的高階函數中,有說過在一個函數中輸出一個函數,則這個函數能夠被成爲高階函數。本文的主角尾調用和它相似,若是一個函數返回的是另外一個函數的調用結果,那麼就被稱爲尾調用。例子:程序員
function add(x, y) {
return x + y;
}
function sum() {
return add(1, 2);
}
複製代碼
如上就是一個尾調用的例子sum函數返回了add的調用結果。但下面的例子就不是尾調用:web
function add(x, y) {
return x + y;
}
// 狀況1
function sum() {
return add(1, 2) + 1;
}
// 狀況2
function sum2() {
let a = add(1, 2);
return a;
}
複製代碼
上例中狀況1和狀況2都不是尾調用,狀況1在調用add函數後還有一個+1的操做,狀況2在調用add函數後還有賦值給a的操做,所以上面的狀況都不是尾調用。編程
遞歸相信你們都知道,就是函數本身調用本身的一種操做。那麼,若是一個函數返回的是本身的調用結果就被稱爲尾遞歸。也就是說尾遞歸必定是尾調用,但尾調用不必定是尾遞歸。先看一個常規遞歸的例子:瀏覽器
function sum(n) {
if (n <= 1) return 1;
return sum(n - 1) + n;
}
sum(10000); // 50005000
複製代碼
如上sum函數就是一個遞歸函數,但他不符合咱們上面對尾調用的定義,所以它不是一個尾調用函數,更不是一個尾遞歸函數。改寫爲尾遞歸函數:閉包
function sum(n, result = 1) {
if (n <= 1) return result;
return sum(n - 1, result + n);
}
sum(10000); // Maximum call stack size exceeded
複製代碼
咱們依然調用sum(10000)但這裏卻報錯了,就是比較常見的堆棧溢出(stack overflow)。關於執行棧(也被稱爲調用棧)不瞭解的能夠參考以前的博文:理解Javascript中的執行上下文和執行棧。函數式編程
如今假設函數A是一個返回了函數B調用結果的函數。函數B是一個返回了函數C結果的函數。相似這樣:
function C() {}
function B() { return C(); }
function A() { return B(); }
A();
複製代碼
當函數A被調用的時候會有一個A的函數執行上下文被壓入執行棧中,B調用的時候會有一個B的執行上下文被壓入執行棧中,直到函數A和函數B都執行結束,對應的執行上下文才會被推出執行棧。若是函數B還返回了一個函數C的調用結果,也會重複這個過程,以此類推,若是這個執行棧內執行上下文的數量超過了最大值那麼就會報出堆棧溢出的錯誤,這是前面的那個例子報錯的原因。看下圖,上面函數的執行棧:
若是函數B中有對函數A中變量的引用,那麼函數A即便執行結束對應的執行上下文也沒法從執行棧中被推出,也就是咱們常說的閉包。但若是函數B中沒有對函數A的引用,執行結束後直接推出函數A的執行上下文多好。
上面的想法若是成真,執行棧中只須要保存上一個函數(最內層函數)的執行上下文就行了,這就是尾調用優化。
**尾調用優化:**對符合要求的尾調用函數,只在執行棧中保存最內層函數的執行上下文的一種實現。
若是咱們優化生效,理想中的執行棧應該是這樣的:
要知道一個執行上下文中保存的信息是不少的,尾調用優化若是生效,執行棧中的執行上下文只會存在一條,所以能夠極大地節約內存。這就是尾調用優化的意義。
但尾調用優化僅僅是普通開發者去寫能夠被優化的函數是作不到的,這個特性通常都須要藉助編譯器或是運行環境來支持才能夠。Javascript原來是不支持尾遞歸調用優化的**,ES6中才開始規定程序引擎應在嚴格模式下使用尾調用優化**。並且ECMAScript 6限定了尾位置不含閉包的尾調用才能進行優化。這和咱們前面說的不謀而合。
但實際筆者通過測試,Chrome( 79.0.3945.130)、Safari( 13.0.3 )都還不支持,也就是說前面那個報堆棧溢出的錯誤依然會報。通過查資料,發現只有低版本的node才曾經支持過尾遞歸調用優化,node(6.0.0)是能夠開啓尾遞歸調用優化的。仍是前面的例子,但開啓了嚴格模式:
'use strict';
function sum(n, result = 1) {
if (n <= 1) return result;
return sum(n - 1, result + n);
}
sum(10000);
複製代碼
咱們看下使用node(6.0.0)調用上面代碼的結果:
RangeError: Maximum call stack size exceeded
at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:4:13)
at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:6:10)
at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:6:10)
at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:6:10)
複製代碼
如上仍是報錯了,堆棧溢出。不論是node仍是瀏覽器對於尾遞歸調用優化默認都是關閉的,在node中須要加一個參數--harmony_tailcalls
才能開啓尾遞歸調用優化。再看下:
$ node --harmony_tailcalls tail-call.js
5000050000
複製代碼
正常返回告終果。修改下代碼咱們看下實際的調用棧:
'use strict';
function sum(n, result = 1) {
console.trace();
if (n <= 1) return result;
return sum(n - 1, result + n);
}
const result = sum(3);
console.log(result);
複製代碼
未開啓尾調用優化以前:
Trace
at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:3:13)
at Object.<anonymous> (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:7:16)
Trace
at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:3:13)
at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:5:10)
at Object.<anonymous> (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:7:16)
Trace
at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:3:13)
at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:5:10)
at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:5:10)
at Object.<anonymous> (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:7:16)
複製代碼
如上打印,無用的信息都被我刪除掉了,咱們再看下開啓尾調用優化以後的:
Trace
at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:3:13)
at Object.<anonymous> (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:7:16)
Trace
at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:3:13)
at Object.<anonymous> (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:7:16)
Trace
at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:3:13)
at Object.<anonymous> (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:7:16)
複製代碼
能夠看到和咱們預期的是同樣的,執行棧中一直只有一個執行上下文。空間複雜度從O(n)被降到了O(1)。大大的節約了內存空間。
這裏留給咱們兩個問題,一個是不開啓尾遞歸調用優化的狀況下堆棧溢出的報錯如何解決,一個是尾遞歸調用既然好處這麼大爲啥要默認關閉呢?。先看第一個問題:
for
循環。根本緣由是執行上下文太多致使的爆棧,那麼不調用函數天然能夠解決這個問題:'use strict';
function sum(n) {
let res = 0;
for (var i = 0; i < n; i++) {
res += i;
}
return res;
}
const result = sum(3);
console.log(result);
複製代碼
for
循環,仍是要調用函數,此時能夠利用彈跳牀函數,所謂彈跳牀函數,至關於函數的一箇中轉站。// 彈跳牀函數,執行函數,若是函數返回類型仍是函數則繼續執行,直到執行結束
function trampoline(f) {
while (f && f instanceof Function) {
f = f();
}
return f;
}
複製代碼
相應的咱們的原函數須要改寫以下:
function sum(n, result = 1) {
if (n <= 1) return result;
return sum.bind(null, n - 1, result + n);
}
複製代碼
此時調用:
trampoline(sum(100000));
複製代碼
就不會報錯堆棧溢出了。
看到這想必必定很好奇,既然尾調用優化如此高效,爲什麼都默認關閉了這個特性呢?答案分爲兩方面:
Chrome下使用尾遞歸寫法的方法依舊出現調用棧溢出的緣由在於:
直接緣由
: 各大瀏覽器(除了safari)根本就沒部署尾調用優化;根本緣由
: 尾調用優化依舊有隱式優化和調用棧丟失的問題;既然尾調用優化是默認關閉的,是否是說尾調用沒什麼用了呢?其實否則,尾調用是函數式編程一個重要的概念,合理的應用尾調用能夠大大提升咱們代碼的可讀性和可維護性,相比帶來的一點性能損失,寫更優雅更易讀的代碼更爲的重要。
以上。
能力有限,水平通常,歡迎勘誤,不勝感激。
訂閱更多文章可關注公衆號「前端進階學習」,回覆「666」,獲取一攬子前端技術書籍