尾調用優化


一、什么是尾調用?
當函數的最后一步是直接返回調用另一個函數,那么這就叫尾調用。
 
function a(x){
  return b(x-1);
}
上面代碼,函數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)的調用記錄。
 
這就叫做"尾調用優化"(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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM