尾調用是函數式編程的一個重要概念,本身非常簡單,一句話就是某個函數的最后一步是調用另一個函數(僅僅調用第一個函數,不用有任何其他操作,否則不屬於尾遞歸)
1.尾遞歸不一定出現在函數尾部,只要是最后一步操作即可
function f(x) { if(x>0){ return m(x); } return n(x); }
上面的代碼中,函數m和n都屬於尾調用,因為他們都是函數f的最后一步操作。
2.遞歸幀和尾遞歸的優化
每一個我們進行遞歸,就會有一個"遞歸幀"壓如棧空間,如A函數遞歸調用B函數,那么A函數的遞歸幀會壓棧,直到B函數釋放棧空間,所以遞歸是一個十分耗費內存的操作,如斐波那契數列
function fib() { if(n===1 || n===2){ return 1; } return f(n-1)+f(n-2); } fib(10) // 55 fib(100) // 棧滿
尾調用由於是函數的最后一步操作,所以不需要保留外層函數的調用幀,因為調用位置,內部變量等信息不會再使用了,直接用內函數的調用幀取代外層函數即可,上代碼
function fib(n,ca1=1,ca2=1){ if(n===1 || n===2){ return ca2; } return fib(n-1,ca2,ca1+ca2); } console.log(fib(100)); //354224848179262000000
這就是尾遞歸的優化,每次執行時棧內只有一個幀,將大大的節省內存,這就是尾遞歸的調用和優化。
“尾遞歸優化"對遞歸操作意義重大,所以一些函數式編程語言將其寫入了規格,es6就是如此,ECMAScript的實現都必須實現尾遞歸優化。
3.遞歸函數的改寫和柯里化(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); console.log(factorial(5));
將尾遞歸函數tailFactorial變為接受1個參數的factorial
方法二,使用es6的函數默認值
function factorial(n,total=1) { if(n===1) return total; return factorial(n-1,total*n); } factorial(5)//120
因為默認值給了1,所以調用時不用提供這個值。
4.嚴格模式
es6的尾調用優化,只在嚴格模式下開啟,正常模式下是無效的,這是因為,正常模式下函數內部有兩個變量,可以跟蹤函數的調用棧
func.arguments:返回調用函數的參數(arguments.callee 得到函數) func.caller: 返回調用當前函數的那個函數。
function fib(n,ca1=1,ca2=1){ if(n===1 || n===2){ return ca2; } console.log(fib.caller); console.log(fib.arguments); return fib(n-1,ca2,ca1+ca2); } console.log(fib(5)); [Function] { '0': 5 } [Function: fib] { '0': 4, '1': 1, '2': 2 } [Function: fib] { '0': 3, '1': 2, '2': 3 } 5
可以看得到調用的參數越來越多。
5.自己實現尾遞歸的優化
我們如何在正常環境下,進行尾遞歸的優化呢?原理非常簡單,尾遞歸之所以需要優化,就是調用太多造成棧溢出,那就要減少調用棧,用循環代替遞歸。
參考文獻:
阮一峰:es6標准入門(第三版)