[翻譯] JS的遞歸與TCO尾調用優化

這兩天搜了下JS遞歸的相關文章, 以爲這篇文章很不錯, 就順手翻譯了下,也算給本身作個筆記,題目是我本身加的。原文很長,寫得也很詳盡,這裏並不是逐字翻譯, 而是做者所講的主要概念加上我本身的一些理解,本文中解決方案的實際意義並非特別大,但算法的邏輯挺有意思,不過也略抽象,理解須要花點時間(囧,估計我太閒了) 文中的用例?所有來自原文:javascript

原文連接:(原題爲:理解JS函數式編程中的遞歸)
Understanding recursion in functional JavaScript programminghtml

遞歸存在的問題

在JS的遞歸調用中,JS引擎將爲每次遞歸開闢一段內存用以儲存遞歸截止前的數據,這些內存的數據結構以「棧」的形式存儲,這種方式開銷很是大,而且通常瀏覽器可用的內存很是有限。
下面這個函數使用遞歸的方式求和:java

//使用遞歸將求和過程複雜化
function sum(x, y) {
    if (y > 0) {
      return sum(x + 1, y - 1);
    } else {
      return x;
    }
}

sum(1, 10); // => 11

當運算規模較小時,這種方式能夠正常輸出結果,但是當把參數變爲sum(1,100000)時,就會形成「棧溢出錯誤(stack overflow 這可不是那個問答網站哦)」瀏覽器就會報錯Uncaught RangeError: Maximum call stack size exceeded算法

尾調用優化 Tail Call Optimisation

在有些語言中,執行尾遞歸時將會被自動識別,繼而在運行時優化成循環的形式,這種優化邏輯大可能是Tail Call Optimisation尾部調用優化,(尾調用概念就是在函數最後一步調用其餘函數,尾遞歸即在最後一步調用自身)關於尾遞歸與尾調優化更詳細的概念解讀能夠看下阮一峯的這篇文章? 尾調用優化 (也就是說執行尾遞歸時,程序無須儲存以前調用棧的值,直接在最後一次遞歸中輸出函數運算結果,這樣就大大節省了內存,而這種優化邏輯就是在代碼執行的時候將其轉換爲循環的形式)
另外在Babel的說明文檔中也提到了尾調用? BABEL Tail Calls編程

以上的sum函數, 使用尾遞歸,將是這個樣子:數組

function sum(x, y) {
    function recur(a, b) {
        if (b > 0) {
            return recur(a + 1, b - 1);
        } else {
            return a;
        }
    }
//尾遞歸即在程序尾部調用自身,注意這裏沒有其餘的運算
    return recur(x, y);
}

sum(1, 10); // => 11

以上這種寫法在有TCO機制的語言中將在執行時內部優化成循環形式而不會產生「棧溢出」錯誤,注意,在當前版本的JS中以上寫法是無效的!由於在當前廣泛的JS版本(ES5)中並無這個優化機制。可是在ES6中已經實現了這個機制 在當前廣泛的JS版本中咱們只能使用替代方案。瀏覽器

這裏插一句:使用Babel能夠在當前JS版本中用ES6的特性(Babel能夠將使用ES6特性編程的代碼轉換成兼容的ES5形式),將原sum()函數輸入Babel的編譯器後,確實被轉換成了循環的形式,感興趣的同窗能夠本身試試:
BABEL編譯器轉換sum()函數的結果以下(對於算法邏輯不太感興趣的同窗看到這裏就差很少了,
能夠直接將一些深遞歸放到Babel中轉換下就能夠了):babel

var _again = true;

  _function: while (_again) {
    var x = _x,
        y = _x2;
    _again = false;

    if (y > 0) {
      _x = x + 1;
      _x2 = y - 1;
      _again = true;
      continue _function;
    } else {
      return x;
    }   } }

替代方案

在當前的JS版本(ES5)中可使用如下方式來優化遞歸。
咱們能夠定義一個Trampolining(蹦牀)函數來解決參數過大形成的「棧溢出」問題。數據結構

//放入trampoline中的函數將被轉換爲函數的輸出結果
function trampoline(f) {
    while (f && f instanceof Function) {
        f = f();
    }
    return f;
}

function sum(x, y) {
    function recur(x, y) {
        if (y > 0) {
          return recur.bind(null, x + 1, y - 1);
        } else {
          return x;
        }
    }
//
    return trampoline(recur.bind(null, x, y));
}

sum(1, 10); // => 11

在以上的方案中, trampoline函數接受一個函數做爲參數,若是參數是函數就被執行後返回,若是參數不是函數將被直接返回,嵌套函數recur中,當y>0時返回一個參數更新了的函數,這個函數被轉入trampoline中循環,直到recur返回xx不是函數因而在trampoline中被直接返回。原文中做者對於每一步都有詳盡的解釋, 感興趣的同窗建議能夠去看看原文。簡單地說:以上邏輯就是將遞歸變成一個條件, 而外層trampoline函數執行這個條件判斷並循環。好吧,接下來更繞的來了-_-#閉包

以上這種方法雖然解決了大參數遞歸的問題,可是卻須要將代碼轉換成trampoline的模式,比較不靈活, 下面做者介紹了一種更靈活方便的方案。

更好的方案

做者在此警告:前方高能, 該方法不須要改動源碼,可是略抽象,理解可能須要花點時間。

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;
        }
    }
}
//這種方式確實有點奇怪,但的確沒有改動不少源碼,只是以直接量的形式使用tco函數包裹源碼
var sum = tco(function(x, y) {
    if (y > 0) {
      return sum(x + 1, y - 1)
    }
    else {
      return x
    }
});
sum(1, 10) // => 11
sum(1, 100000) // => 100001 沒有形成棧溢出
  • 首先以函數表達式的形式將tco函數的返回值賦給sum,tco函數的返回值是accumulator函數,也就是說當執行sum(1,10)的時候便是在執行accumulator(1,10),牢記這點對後續理解頗有幫助。

  • accumulator是個閉包,這意味着能夠訪問在tco中定義的valueactive以及accumulated

  • 前面已經講了,當咱們執行sum的時候至關因而執行accumulator,因而accumulator 將實參傳入accumulated數組,好比執行sum(1,10)那麼這裏傳入的就是類數組對象[1,10],accumulated如今就是一個length爲1的二維數組。

  • 進入while循環,這裏是關鍵:value = f.apply(this, accumulated.shift()); 在這條語句中, f表示外包的匿名函數,它判斷y的值後返回一個sum (這裏很容易產生混亂,若是咱們忽略while循環中的細節,很容易將其誤認爲也是遞歸)

  • 匿名函數f判斷y的值返回一個sumsum的參數被改變了,前面提到執行sum至關於執行accumulator,因而新的參數被加入到了accumulator可是由於這時active的值依然是true(由於如今執行流還在while循環裏),因此執行這個被返回的sum就會獲得一個undefined的值,value被賦值爲undefined

  • 但是由於執行了被返回的sum(也就是執行了accumulator)儘管沒有進入if(!active),可是執行了第一條語句,因此accumulated被從新push進了在外包的匿名函數中被修改的實參,因此while循環繼續(理解這裏是關鍵)。

  • while循環一直執行到accumulated中的值爲空, 在value = f.apply(this, accumulated.shift()); 每次return一次sum後accumulated 都會被從新推入一個實參(accumulated的length始終爲1),直到匿名的外包函數return出x,因而x的值被賦給value被返回出來。

注意:以上主要仍是根據我本身的理解來闡述邏輯, 確實比較繞,做者原文寫得更加詳細

總結

以上方法就是在不改動源碼的狀況下實現的TCO優化, 做者在該文章的Update中介紹了另外的非TCO的優化遞歸的方法,不過篇幅有限就再也不貼出來了,就我自身感受而言,若是對算法的邏輯實現不感興趣, 大能夠直接用Babel將深遞歸轉換成優化後的形式。另外這也有一篇介紹JS中遞歸與循環的的文章,其中也有TCO優化的相關介紹:
?Recursion in Functional JavaScript

感受以上代碼的實際意義可能並無那麼大, 爲了寫這篇博客也是耗了我一天,囧rz,但也挺佩服這哥們:「我靠,這也能想獲得!」

相關文章
相關標籤/搜索