1 什么是尾調用?
尾調用( 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; } // 情況三 function f(x){ g(x); }
上面代碼中,情況一是調用函數 g 之后,還有賦值操作,所以不屬於尾調用,即使語義完全一樣。情況二也屬於調用后還有操作,即使寫在一行內。情況三等同於下面的代碼。
function f(x){ g(x); return undefined; }
尾調用不一定出現在函數尾部,只要是最后一步操作即可。
function f(x) { if (x > 0) { return m(x) } return n(x); }
上面代碼中,函數 m 和 n 都屬於尾調用,因為它們都是函數 f 的最后一步操作。
2 尾調用優化
尾調用之所以與其他調用不同,就在於它的特殊的調用位置。
我們知道,函數調用會在內存形成一個 “ 調用記錄 ” ,又稱 “ 調用幀 ” ( 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(x) 的調用幀,只保留 g(3) 的調用幀。
這就叫做 “ 尾調用優化 ” ( Tail call optimization ),即只保留內層函數的調用幀。如果所有函數都是尾調用,那么完全可以做到每次執行時,調用幀只有一項,這將大大節省內存。這就是 “ 尾調用優化 ” 的意義。
注意,只有不再用到外層函數的內部變量,內層函數的調用幀才會取代外層函數的調用幀,否則就無法進行 “ 尾調用優化 ” 。
function addOne(a){ var one = 1; function inner(b){ return b + one; } return inner(a); }
上面的函數不會進行尾調用優化,因為內層函數inner用到了外層函數addOne的內部變量one。
3 尾遞歸
函數調用自身,稱為遞歸。如果尾調用自身,就稱為尾遞歸。
遞歸非常耗費內存,因為需要同時保存成千上百個調用幀,很容易發生 “ 棧溢出 ” 錯誤( 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
還有一個比較著名的例子,就是計算 fibonacci 數列,也能充分說明尾遞歸優化的重要性
如果是非尾遞歸的 fibonacci 遞歸方法
function Fibonacci (n) { if ( n <= 1 ) {return 1}; return Fibonacci(n - 1) + Fibonacci(n - 2); } Fibonacci(10); // 89 // Fibonacci(100) // Fibonacci(500) // 堆棧溢出了
如果我們使用尾遞歸優化過的 fibonacci 遞歸算法
function Fibonacci2 (n , ac1 = 1 , ac2 = 1) { if( n <= 1 ) {return ac2}; return Fibonacci2 (n - 1, ac2, ac1 + ac2); } Fibonacci2(100) // 573147844013817200000 Fibonacci2(1000) // 7.0330367711422765e+208 Fibonacci2(10000) // Infinity
由此可見, “ 尾調用優化 ” 對遞歸操作意義重大,所以一些函數式編程語言將其寫入了語言規格。 ES6 也是如此,第一次明確規定,所有 ECMAScript 的實現,都必須部署 “ 尾調用優化 ” 。這就是說,在 ES6 中,只要使用尾遞歸,就不會發生棧溢出,相對節省內存。
4 遞歸函數的改寫
尾遞歸的實現,往往需要改寫遞歸函數,確保最后一步只調用自身。做到這一點的方法,就是把所有用到的內部變量改寫成函數的參數。比如上面的例子,階乘函數 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 ),只需要知道循環可以用遞歸代替,而一旦使用遞歸,就最好使用尾遞歸。
5 嚴格模式
ES6 的尾調用優化只在嚴格模式下開啟,正常模式是無效的。
這是因為在正常模式下,函數內部有兩個變量,可以跟蹤函數的調用棧。
func.arguments:返回調用時函數的參數。
func.caller:返回調用當前函數的那個函數。
尾調用優化發生時,函數的調用棧會改寫,因此上面兩個變量就會失真。嚴格模式禁用這兩個變量,所以尾調用模式僅在嚴格模式下生效。
function restricted() { "use strict"; restricted.caller; // 報錯 restricted.arguments; // 報錯 } restricted();
6 尾遞歸優化的實現
尾遞歸優化只在嚴格模式下生效,那么正常模式下,或者那些不支持該功能的環境中,有沒有辦法也使用尾遞歸優化呢?回答是可以的,就是自己實現尾遞歸優化。
它的原理非常簡單。尾遞歸之所以需要優化,原因是調用棧太多,造成溢出,那么只要減少調用棧,就不會溢出。怎么做可以減少調用棧呢?就是采用 “ 循環 ” 換掉 “ 遞歸 ” 。
下面是一個正常的遞歸函數。
function sum(x, y) { if (y > 0) { return sum(x + 1, y - 1); } else { return x; } } sum(1, 100000)
上面代碼中,sum是一個遞歸函數,參數x是需要累加的值,參數y控制遞歸次數。一旦指定sum遞歸 100000 次,就會報錯,提示超出調用棧的最大次數。
蹦床函數( trampoline )可以將遞歸執行轉為循環執行。
function trampoline(f) { while (f && f instanceof Function) { f = f(); } return f; }
上面就是蹦床函數的一個實現,它接受一個函數f作為參數。只要f執行后返回一個函數,就繼續執行。注意,這里是返回一個函數,然后執行該函數,而不是函數里面調用函數,這樣就避免了遞歸執行,從而就消除了調用棧過大的問題。
然后,要做的就是將原來的遞歸函數,改寫為每一步返回另一個函數。
function sum(x, y) { if (y > 0) { return sum.bind(null, x + 1, y - 1); } else { return x; } }
上面代碼中,sum函數的每次執行,都會返回自身的另一個版本。
現在,使用蹦床函數執行sum,就不會發生調用棧溢出。
trampoline(sum(1, 100000)) // 100001 //蹦床函數並不是真正的尾遞歸優化,下面的實現才是。 function tco(f) { var value; var active = false; var accumulated = []; return function accumulator() { accumulated.push(arguments); if (!active) { active = true; while (accumulated.length) { value = f.apply(this, accumulated.shift()); } active = false; return value; } }; } var sum = tco(function(x, y) { if (y > 0) { return sum(x + 1, y - 1) }else { return x } }); sum(1, 100000) // 100001
上面代碼中,tco函數是尾遞歸優化的實現,它的奧妙就在於狀態變量active。默認情況下,這個變量是不激活的。一旦進入尾遞歸優化的過程,這個變量就激活了。然后,每一輪遞歸sum返回的都是undefined,所以就避免了遞歸執行;而accumulated數組存放每一輪sum執行的參數,總是有值的,這就保證了accumulator函數內部的while循環總是會執行。這樣就很巧妙地將 “ 遞歸 ” 改成了 “ 循環 ” ,而后一輪的參數會取代前一輪的參數,保證了調用棧只有一層。
