轉自:http://www.ruanyifeng.com/blog/2015/04/tail-call.htmljavascript
尾調用(Tail Call)是函數式編程的一個重要概念,本文介紹它的含義和用法。html
尾調用的概念很是簡單,一句話就能說清楚,就是指某個函數的最後一步是調用另外一個函數。java
function f(x){ return g(x); }
上面代碼中,函數f的最後一步是調用函數g,這就叫尾調用。es6
如下兩種狀況,都不屬於尾調用。編程
// 狀況一 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)。this
尾調用因爲是函數的最後一步操做,因此不須要保留外層函數的調用記錄,由於調用位置、內部變量等信息都不會再用到了,只要直接用內層函數的調用記錄,取代外層函數的調用記錄就能夠了。
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
:返回調用當前函數的那個函數。
尾調用優化發生時,函數的調用棧會改寫,所以上面兩個變量就會失真。嚴格模式禁用這兩個變量,因此尾調用模式僅在嚴格模式下生效。
(完)