一、什么是尾調用?
當函數的最后一步是直接返回調用另一個函數,那么這就叫尾調用。
function a(x){
return b(x-1);
}
上面代碼,函數a最后一步是調用函數b,這就叫尾調用。
上面代碼,函數a最后一步是調用函數b,這就叫尾調用。
function a(x){
if (x > 0) return b(x)
return c(x);
}
上面代碼,函數b和c都屬於尾調用,因為它們都是函數a的最后一步操作。
以下兩種情況,都不屬於尾調用。
function a(x){
let y = b(x);
return y;
}
調用函數b之后,用變量保存,再返回變量值.即使結果一致,但語義不同.
function a(x){
return b(x) + 1;
}
調用后還有操作,也是不行的。
二、尾調用優化
尾調用之所以與其他調用不同,就在於它的特殊的調用位置。函數調用會在內存形成一個"調用記錄",又稱"調用幀"(call frame),保存調用位置和內部變量等信息。
如果在函數A的內部調用函數B,那么在A的調用記錄上方,還會形成一個B的調用記錄。等到B運行結束,將結果返回到A,B的調用記錄才會消失。
如果函數B內部還調用函數C,那就還有一個C的調用記錄棧,以此類推。所有的調用記錄,就形成一個"調用棧"(call stack)。
而尾調用時,由於是函數的最后一步操作,所以不需要保留外層函數的調用記錄,因為調用位置、內部變量等信息都不會再用到了,只要直接用內層函數的調用記錄,取代外層函數的調用記錄就可以了。
function a() {
let m = 1;
let n = 2;
return f(m + n);
}
//等同於
function a() {
return f(3);
}
//等同於
f(3);
上面代碼中,如果函數f()不是尾調用,函數a()就需要保存內部變量m和n的值、f()的調用位置等信息。
但由於調用f()之后,函數a()就結束了,所以執行到最后一步,完全可以刪除a() 的調用記錄,只保留f(3)的調用記錄。
但由於調用f()之后,函數a()就結束了,所以執行到最后一步,完全可以刪除a() 的調用記錄,只保留f(3)的調用記錄。
這就叫做"尾調用優化"(Tail call optimization),即只保留內層函數的調用記錄。
如果所有函數都是尾調用,那么完全可以做到每次執行時,調用記錄只有一項,這將大大節省內存。這就是"尾調用優化"的意義。
如果所有函數都是尾調用,那么完全可以做到每次執行時,調用記錄只有一項,這將大大節省內存。這就是"尾調用優化"的意義。
三、尾遞歸
如果遞歸函數尾調用自身,就稱為尾遞歸。
遞歸非常耗費內存,因為需要同時保存成千上百個調用記錄,很容易發生"棧溢出"錯誤(stack overflow)。
對於尾遞歸來說,由於只存在一個調用記錄,所以永遠不會發生"棧溢出"錯誤。
對於尾遞歸來說,由於只存在一個調用記錄,所以永遠不會發生"棧溢出"錯誤。
//計算n的階乘,最多需要保存n個調用記錄,復雜度 O(n)
function factorial(n){
if (n === 1) return 1;
return n * factorial(n - 1);
}
//改寫成尾遞歸,只保留一個調用記錄,復雜度 O(1)
function factorial(n, total = 1){
//total是用來保存累計的結果值
if(n === 1) return total;
return factorial(n - 1, n * total);
}
factorial(5, 1) // 120
四、遞歸函數的改寫
尾遞歸的實現,需要確保最后一步只調用自身。做到這一點的方法,就是把所有用到的內部變量改寫成函數的參數。
遞歸本質上是一種循環操作。純粹的函數式編程語言沒有循環操作命令,所有的循環都用遞歸實現,所以尾遞歸對這些語言極其重要。
對於其他支持"尾調用優化"的語言(比如Lua,ES6),只需要知道循環可以用遞歸代替,而一旦使用遞歸,就最好使用尾遞歸。
來源: http://www.ruanyifeng.com/blog/2015/04/tail-call.html
遞歸本質上是一種循環操作。純粹的函數式編程語言沒有循環操作命令,所有的循環都用遞歸實現,所以尾遞歸對這些語言極其重要。
對於其他支持"尾調用優化"的語言(比如Lua,ES6),只需要知道循環可以用遞歸代替,而一旦使用遞歸,就最好使用尾遞歸。
來源: http://www.ruanyifeng.com/blog/2015/04/tail-call.html