尾呼叫(Tail Call)是函數語言程式設計的一個重要概念,本文介紹它的含義和用法。
一、什麼是尾呼叫?
尾呼叫的概念非常簡單,一句話就能說清楚,就是指某個函式的最後一步是呼叫另一個函式。
function f(x){ return g(x); }
上面程式碼中,函式f的最後一步是呼叫函式g,這就叫尾呼叫。
以下兩種情況,都不屬於尾呼叫。
// 情況一 function f(x){ let y = g(x); return y; } // 情況二 function f(x){ return g(x) + 1; }
上面程式碼中,情況一是呼叫函式g之後,還有別的操作,所以不屬於尾呼叫,即使語義完全一樣。情況二也屬於呼叫後還有操作,即使寫在一行內。
尾呼叫不一定出現在函式尾部,只要是最後一步操作即可。
function f(x) { if (x > 0) { return m(x) } return n(x); }
上面程式碼中,函式m和n都屬於尾呼叫,因為它們都是函式f的最後一步操作。
二、尾呼叫優化
尾呼叫之所以與其他呼叫不同,就在於它的特殊的呼叫位置。
我們知道,函式呼叫會在記憶體形成一個"呼叫記錄",又稱"呼叫幀"(call frame),儲存呼叫位置和內部變數等資訊。如果在函式A的內部呼叫函式B,那麼在A的呼叫記錄上方,還會形成一個B的呼叫記錄。等到B執行結束,將結果返回到A,B的呼叫記錄才會消失。如果函式B內部還呼叫函式C,那就還有一個C的呼叫記錄棧,以此類推。所有的呼叫記錄,就形成一個"呼叫棧"(call stack)。
尾呼叫由於是函式的最後一步操作,所以不需要保留外層函式的呼叫記錄,因為呼叫位置、內部變數等資訊都不會再用到了,只要直接用內層函式的呼叫記錄,取代外層函式的呼叫記錄就可以了。
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) 的呼叫記錄。
這就叫做"尾呼叫優化"(Tail call optimization),即只保留內層函式的呼叫記錄。如果所有函式都是尾呼叫,那麼完全可以做到每次執行時,呼叫記錄只有一項,這將大大節省記憶體。這就是"尾呼叫優化"的意義。
三、尾遞迴
函式呼叫自身,稱為遞迴。如果尾呼叫自身,就稱為尾遞迴。
遞迴非常耗費記憶體,因為需要同時儲存成千上百個呼叫記錄,很容易發生"棧溢位"錯誤(stack overflow)。但對於尾遞迴來說,由於只存在一個呼叫記錄,所以永遠不會發生"棧溢位"錯誤。
function factorial(n) { if (n === 1) return 1; return n * factorial(n - 1); } factorial(5) // 120
上面程式碼是一個階乘函式,計算n的階乘,最多需要儲存n個呼叫記錄,複雜度 O(n) 。
如果改寫成尾遞迴,只保留一個呼叫記錄,複雜度 O(1) 。
function factorial(n, total) { if (n === 1) return total; return factorial(n - 1, n * total); } factorial(5, 1) // 120
由此可見,"尾呼叫優化"對遞迴操作意義重大,所以一些函數語言程式設計語言將其寫入了語言規格。ES6也是如此,第一次明確規定,所有 ECMAScript 的實現,都必須部署"尾呼叫優化"。這就是說,在 ES6 中,只要使用尾遞迴,就不會發生棧溢位,相對節省記憶體。
四、遞迴函式的改寫
尾遞迴的實現,往往需要改寫遞迴函式,確保最後一步只呼叫自身。做到這一點的方法,就是把所有用到的內部變數改寫成函式的引數。比如上面的例子,階乘函式 factorial 需要用到一箇中間變數 total ,那就把這個中間變數改寫成函式的引數。這樣做的缺點就是不太直觀,第一眼很難看出來,為什麼計算5的階乘,需要傳入兩個引數5和1?
兩個方法可以解決這個問題。方法一是在尾遞迴函式之外,再提供一個正常形式的函式。
function tailFactorial(n, total) { if (n === 1) return total; return tailFactorial(n - 1, n * total); } function factorial(n) { return tailFactorial(n, 1); } factorial(5) // 120
上面程式碼通過一個正常形式的階乘函式 factorial ,呼叫尾遞迴函式 tailFactorial ,看起來就正常多了。
函數語言程式設計有一個概念,叫做柯里化(currying),意思是將多引數的函式轉換成單引數的形式。這裡也可以使用柯里化。
function currying(fn, n) { return function (m) { return fn.call(this, m, n); }; } function tailFactorial(n, total) { if (n === 1) return total; return tailFactorial(n - 1, n * total); } const factorial = currying(tailFactorial, 1); factorial(5) // 120
上面程式碼通過柯里化,將尾遞迴函式 tailFactorial 變為只接受1個引數的 factorial 。
第二種方法就簡單多了,就是採用ES6的函式預設值。
function factorial(n, total = 1) { if (n === 1) return total; return factorial(n - 1, n * total); } factorial(5) // 120
上面程式碼中,引數 total 有預設值1,所以呼叫時不用提供這個值。
總結一下,遞迴本質上是一種迴圈操作。純粹的函數語言程式設計語言沒有迴圈操作命令,所有的迴圈都用遞迴實現,這就是為什麼尾遞迴對這些語言極其重要。對於其他支援"尾呼叫優化"的語言(比如Lua,ES6),只需要知道迴圈可以用遞迴代替,而一旦使用遞迴,就最好使用尾遞迴。
([說明] 本文摘自我寫的《ECMAScript 6入門》)
五、嚴格模式
ES6的尾呼叫優化只在嚴格模式下開啟,正常模式是無效的。
這是因為在正常模式下,函式內部有兩個變數,可以跟蹤函式的呼叫棧。
arguments
:返回撥用時函式的引數。func.caller
:返回撥用當前函式的那個函式。
尾呼叫優化發生時,函式的呼叫棧會改寫,因此上面兩個變數就會失真。嚴格模式禁用這兩個變數,所以尾呼叫模式僅在嚴格模式下生效。
六、參考連結
- 尾呼叫,from 維基百科
- Tail Calls, Default Arguments, and Excessive Recycling in ES-6, by raganwald
- Proper Tail Calls, from programming in Lua
(完)