ES6尾調用和尾遞歸

什麼是尾調用?

尾調用(Tail Call)是函數式編程的一個重要概念,自己很是簡單,一句話就能說清楚,就是指某個函數的最後一步是調用另外一個函數。javascript

function f(x){
  return g(x);
}
複製代碼

上面代碼中,函數f的最後一步是調用函數g,這就叫尾調用。 如下三種狀況,都不屬於尾調用。html

// 狀況一
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;
}
複製代碼

尾調用不必定出如今函數尾部,只要是最後一步操做便可。java

function f(x) {
  if (x > 0) {
    return m(x)
  }
  return n(x);
}
複製代碼

上面代碼中,函數m和n都屬於尾調用,由於它們都是函數f的最後一步操做。編程

尾調用優化

嚴格模式 在講尾調用前,必須提到嚴格模式,ES6 的尾調用優化只在嚴格模式下開啓,正常模式是無效的。 這是由於在正常模式下,函數內部有兩個變量,能夠跟蹤函數的調用棧。 * func.arguments:返回調用時函數的參數。 * func.caller:返回調用當前函數的那個函數。 尾調用優化發生時,函數的調用棧會改寫,所以上面兩個變量就會失真。嚴格模式禁用這兩個變量,因此尾調用模式僅在嚴格模式下生效。數組

function restricted() {
 'use strict';
  restricted.caller;    // 報錯
  restricted.arguments; // 報錯
}
restricted();
複製代碼

尾調用優化 尾調用之因此與其餘調用不一樣,就在於它的特殊的調用位置。 咱們知道,函數調用會在內存造成一個「調用記錄」,又稱「調用幀」(call frame),保存調用位置和內部變量等信息。若是在函數A的內部調用函數B,那麼在A的調用幀上方,還會造成一個B的調用幀。等到B運行結束,將結果返回到A,B的調用幀纔會消失。若是函數B內部還調用函數C,那就還有一個C的調用幀,以此類推。全部的調用幀,就造成一個「調用棧」(call stack)。瀏覽器

舉個例子app

function foo () { console.log(111); }
function bar () { foo(); }
function baz () { bar(); }

baz();
複製代碼

形成這種結果是由於每一個函數在調用另外一個函數的時候,並無 return 該調用,因此JS引擎會認爲你尚未執行完,會保留你的調用幀。編程語言

baz() 裏面調用了 bar() 函數,並無 return 該調用,因此在調用棧中保持本身的調用幀,同時 bar() 函數的調用幀在調用棧中生成,同理,bar() 函數又調用了 foo() 函數,最後執行到 foo() 函數的時候,沒有再調用其餘函數,這裏沒有顯示聲明 return,因此這裏默認 return undefined函數式編程

foo() 執行完了,銷燬調用棧中本身的記錄,依次銷燬 bar()baz() 的調用幀,最後完成整個流程。

若是對上面的例子作以下修改:

function foo () { console.log(111); }
function bar () { return foo(); }
function baz () { return bar(); }

baz();
複製代碼

咱們能夠很清楚的看到,尾調用因爲是函數的最後一步操做,因此不須要保留外層函數的調用記錄,只要直接用內層函數的調用記錄取代外層函數的調用記錄就能夠了,調用棧中始終只保持了一條調用幀。

這就叫作尾調用優化,若是全部的函數都是尾調用的話,那麼在調用棧中的調用幀始終只有一條,這樣會節省很大一部分的內存,這也是尾調用優化的意義

尾調用因爲是函數的最後一步操做,因此不須要保留外層函數的調用幀,由於調用位置、內部變量等信息都不會再用到了,只要直接用內層函數的調用幀,取代外層函數的調用幀就能夠了。

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。 注意,目前只有 Safari 瀏覽器支持尾調用優化,Chrome 和 Firefox 都不支持。

尾遞歸優化

函數調用自身,稱爲遞歸。若是尾調用自身,就稱爲尾遞歸。 遞歸很是耗費內存,由於須要同時保存成千上百個調用幀,很容易發生「棧溢出」錯誤(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),只須要知道循環能夠用遞歸代替,而一旦使用遞歸,就最好使用尾遞歸。

尾遞歸優化的實現

尾遞歸優化只在嚴格模式下生效,那麼正常模式下,或者那些不支持該功能的環境中,有沒有辦法也使用尾遞歸優化呢?回答是能夠的,就是本身實現尾遞歸優化。

它的原理很是簡單。尾遞歸之因此須要優化,緣由是調用棧太多,形成溢出,那麼只要減小調用棧,就不會溢出。怎麼作能夠減小調用棧呢?就是採用「循環」換掉「遞歸」。

下面是一個正常的遞歸函數。

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循環老是會執行。這樣就很巧妙地將「遞歸」改爲了「循環」,然後一輪的參數會取代前一輪的參數,保證了調用棧只有一層。


參考:


我是Cloudy,年輕的前端攻城獅一枚,愛專研,愛技術,愛分享。 我的筆記,整理不易,感謝閱讀、點贊和收藏。 文章有任何問題歡迎你們指出,也歡迎你們一塊兒交流前端各類問題!

相關文章
相關標籤/搜索