es6出來已經很長時間了,平時工作中也會用到很多es6的新特性,自以為很多東西已經了解清楚了。
周末有空從頭開始把軟大神寫的es6從頭開始又看了一遍,發現好多東西都是囫圇吞棗,很多概念都沒理解清楚。特別是在函數的擴展模塊,以前壓根就沒看到最后,關于遞歸尾調用這里,概念都不清楚。
之前寫的權限框架涉及很多tree的遞歸遍歷,遞歸消耗性能,遞歸嵌套層級過多容易導致棧溢出,這個問題眾所周知,所以怎么處理遞歸調用就很重要,這個尾調用優化正好就是解決這個問題。
關于尾調用的概念,直接引用阮一峰的文檔,也可以直接查看ES6文檔http://es6.ruanyifeng.com/#docs/function
一、階乘
1.遞歸
const { log } = console
//獲取階乘
//方式一:遞歸 復雜度O(n)
function getJc(n){
if(n===1) return 1
return n * getJc(n-1)
}
log('遞歸',getJc(4))
2.遞歸優化
//方式二:遞歸優化 復雜度O(1)
function getJc1(n,x){
if(n===1) return x
return getJc1(n-1, x * n)
}
log('遞歸優化',getJc1(6,1))
3.循環
//方式三:循環 效率高,缺點是定義變量多,變量改變頻繁
function getJc2(n){
if( n <= 2 ) return n
let cj = 1
let n1 = 1
for( let i = 1; i <= n; i++ ){
n1 = cj
cj = n1 * i
}
return cj
}
log('循環',getJc2(6))
4.柯里化
//這種方式只是將傳參方式改變了下,本質還是尾調用優化處理
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
二、斐波那契數列
1.遞歸
// 廣度優先遍歷
let tempArr = []
function f(tree){
tempArr = []
tree.forEach(item=>{
console.log(item.id)
if(item.child){
tempArr = tempArr.concat(item.child)
}
})
tempArr.length > 0 && f(tempArr)
}
const tree = [{id:1,child:
[{id:2,child:[{id:4,child:[{id:8,id:9}]}]}]}]
f(tree) //1 2 4 9
// 深度優先遍歷
let temArr = []
function f(tree){
tree.forEach(item=>{
console.log('item.id',item.id)
if(item.child){
f(item.child)
}
})
}
const { log } = console
//斐波那契數列
//方式一:遞歸
function Fibonacci (n) {
if ( n <= 1 ) {return 1};
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
log('遞歸',Fibonacci(9))
2.遞歸尾調用優化
//方式二:尾遞歸調用優化 優點:減少重復計算,性能高,代碼優雅
// 缺點:代碼理解難度大
function Fibonacci1 (n,x=1,y=1) {
if ( n <= 1 ) {return y};
return Fibonacci1(n - 1,y,x+y);
}
log('尾調用優化',Fibonacci1(9))
function curr1(fn,x,y){
return function(n){
return fn.call(this,n,x,y)
}
}
const n1 = curr1(Fibonacci1,1,1)
n1(8) // 21
n1(6) // 8
3.循環
//方式三:循環 優點:無重復計算,速度快;缺點:變量多,改變頻繁
function Fibonacci2 (n) {
if ( n <= 2 ) {return n};
let n1 = 1,n2 = 1, sum = 0
for(let i = 2; i <= n; i++){
sum = n1 + n2
n1 = n2
n2 = sum
}
return sum;
}
log('循環',Fibonacci2(80))
4.遞歸?緩存
//方式四:遞歸?緩存
function memozi(fn){
let obj = {}
return function(n){
if(!obj[n]){
return obj[n] = fn(n)
}else{
return obj[n]
}
}
}
const Fibonacci3 = memozi ( function(n) {
if ( n <= 2 ) {return n};
return Fibonacci3(n-1) + Fibonacci3(n-2);
})
log('遞歸?緩存',Fibonacci3(80))
以下,引用于阮一峰《ECMAScript 6 入門》
http://es6.ruanyifeng.com/#docs/function
一、什么是尾調用?
尾調用(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);
}
//情況三等同于
function f(x){
g(x);
return undefined;
}
上面代碼中,情況一是調用函數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(x)的調用幀,只保留g(3)的調用幀。
這就叫做“尾調用優化”(Tail call optimization),即只保留內層函數的調用幀。如果所有函數都是尾調用,那么完全可以做到每次執行時,調用幀只有一項,這將大大節省內存。這就是“尾調用優化”的意義。
注意,只有不再用到外層函數的內部變量,內層函數的調用幀才會取代外層函數的調用幀,否則就無法進行“尾調用優化”。
function addOne(a){
var one = 1;
function inner(b){
return b + one;
}
return inner(a);
}
上面的函數不會進行尾調用優化,因為內層函數inner用到了外層函數addOne的內部變量one。
三、尾遞歸
函數調用自身,稱為遞歸。如果尾調用自身,就稱為尾遞歸。
遞歸非常耗費內存,因為需要同時保存成千上百個調用幀,很容易發生“棧溢出”錯誤(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 中只要使用尾遞歸,就不會發生棧溢出(或者層層遞歸造成的超時),相對節省內存。
四、遞歸函數的改寫
尾遞歸的實現,往往需要改寫遞歸函數,確保最后一步只調用自身。做到這一點的方法,就是把所有用到的內部變量改寫成函數的參數。比如上面的例子,階乘函數 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變為只接受一個參數的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),只需要知道循環可以用遞歸代替,而一旦使用遞歸,就最好使用尾遞歸。
五、嚴格模式
ES6 的尾調用優化只在嚴格模式下開啟,正常模式是無效的。
這是因為在正常模式下,函數內部有兩個變量,可以跟蹤函數的調用棧。
- func.arguments:返回調用時函數的參數。
- func.caller:返回調用當前函數的那個函數。
尾調用優化發生時,函數的調用棧會改寫,因此上面兩個變量就會失真。嚴格模式禁用這兩個變量,所以尾調用模式僅在嚴格模式下生效。
function restricted() {
'use strict';
restricted.caller; // 報錯
restricted.arguments; // 報錯
}
restricted();
六、尾遞歸優化的實現
尾遞歸優化只在嚴格模式下生效,那么正常模式下,或者那些不支持該功能的環境中,有沒有辦法也使用尾遞歸優化呢?回答是可以的,就是自己實現尾遞歸優化。
它的原理非常簡單。尾遞歸之所以需要優化,原因是調用棧太多,造成溢出,那么只要減少調用棧,就不會溢出。怎么做可以減少調用棧呢?就是采用“循環”換掉“遞歸”。
下面是一個正常的遞歸函數。
function sum(x, y) {
if (y > 0) {
return sum(x + 1, y - 1);
} else {
return x;
}
}
sum(1, 100000)
// Uncaught RangeError: Maximum call stack size exceeded(…)
上面代碼中,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循環總是會執行。這樣就很巧妙地將“遞歸”改成了“循環”,而后一輪的參數會取代前一輪的參數,保證了調用棧只有一層。
以上,引用于阮一峰《ECMAScript 6 入門》
http://es6.ruanyifeng.com/#docs/function