JS 尾調優化

概述

尾調

在說尾調優化(Tail Call Optimization,下文簡稱 TCO)前,先解釋什么是尾調——Tail Call。

通俗來說,尾調就是一個出現在另一個函數“結尾”處的函數調用。

舉個簡單的例子,如下所示:foo 的調用出現在 bar 的結尾處;foo 返回后,就沒bar啥事了(除了可能要繼續返回結果外)。我們就把foo(x) 叫做 bar 函數的尾調。

function foo(x) {
  return x;
}

function bar(y) {
  const x = y + 1;
  return foo(x); // Tail call
}

再舉個反例,下面的 baz 就沒有尾調:因為 foo(z) 完成后,還需要加 1 才能由 baz 返回;同理 foo(z) + 1 這種也不屬于尾調。

function baz(z) {
  return 1 + foo(z); // Not tail call
}

調用棧 & 棧幀

本文概念比較雜,開始這節知識前,也得插播一點程序設計的小知識。學習過 JVM、V8,或是 C 內核知識的小伙伴,對調用棧(call stack)、堆(heap)、棧幀(stack frame)這些概念應該不會太陌生,簡單來說:

  • 調用棧是所有方法執行的內存模型(先進后出的連續內存空間)
  • 每個方法被調用時,會在調用棧中創建一個棧幀;棧幀包括方法的局部變量、出口地址、操作數等等信息
  • 而方法中使用到的對象被存放在內(JS 世界里,function 也是對象)

我們還是以 foobar 為例,看看程序運行時的調用情況。

Normal call
  1. 第一步自然是全局的初始化,將全局變量(foobar)打包成一個棧幀放入調用棧中
  2. 程序掃描到(A)處時,bar(1)被調用;程序進入bar函數體內,返回地址(address A)、參數、局部變量等等組成新的棧幀,并放入調用棧頭部
  3. 程序繼續掃描到(B)處,foo(x)被調用;程序進入foo函數體內,返回地址(address B)、參數又成為新的棧幀放入調用棧頭部

之后的故事就是程序執行到(C)處,返回結果;調用棧彈出棧幀,并根據棧幀內的返回地址一路回到(A)處;最后程序結束。

尾調優化(TCO)

通常來說,調用棧的空間會有限制,也即棧幀的數量是有限的——幾千到幾萬不等;一旦超過這個限度,就會拋一個經典的錯誤——Stack overflow。向上面那樣簡單的代碼片段自然很難導致棧空間溢出啦,但是如果使用遞歸,幾萬個棧幀就不算個事了。

遞歸后文再講,我們再回到 foobar 的調用上。大家有沒有發現,上圖中 foo 函數的棧幀(綠色區域)并不是必須的,完全可以復用 bar 函數的棧幀(藍色區域):因為 bar 的計算邏輯已經結束了呀,留著也只是為了彈棧而已。

所謂的 TCO 就是做了這么個優化:當偵測到當前函數是尾調用時,就復用之前的棧幀。如下圖所示,通過 TCO 優化,我們就節省了一個棧幀的空間。如果尾調的數量有成千上萬個的話,TCO 就可以很好的避免 Stack Overflow 了。

TCO

尾遞歸函數

書接上文,TCO 主要是用來防止 Stack Overflow 的,但是簡單的代碼片段幾乎沒有棧空間溢出的可能,只有遞歸函數才有消耗幾萬個棧幀的可能。那 TCO 又是怎么優化遞歸函數的呢?

答案是只能靠開發人員主動地改變遞歸寫法,寫成尾遞歸的形式。那何為尾遞歸呢?就是使用了尾調的遞歸函數!我們看個簡單的 sum 函數:

function sum(n) {
  if (n <= 1) return n;
  return n + sum(n - 1);
}

上面這個 sum 函數是用來求 1 ~ n 的正整數和的,很經典的遞歸函數;但是根據上文的定義,它顯然不是尾調函數—— sum(n-1) 調用結束后并未直接返回;而且當 n 的值大于十萬時,必然 Stack Overflow!大家可以試試,在JS環境里會拋出 Maximum call stack size exceeded 的異常。所以我們要改一下寫法——改成尾調的形式:

function sum(n, pre = 0) {
  if (n <= 1) return n + pre;
  return sum(n - 1, n + prev);
}

稍微解釋一下,這個尾調 sum 會把上一步的計算結果當做參數傳給下一次調用,這樣形式上就成了尾調函數了。這種尾調形式的遞歸函數,就是所謂的尾遞歸函數了。大家可以試試上面這個例子,在嚴格模式下(注意必須在嚴格模式下)的 nodejs 或是主流的瀏覽器里跑尾遞歸 sum,是不會拋異常的。

Continuation-passing style

那這里又有一個問題了,TCO 需要開發人員主動地將普通遞歸函數改寫成尾遞歸函數,上面的 sum 自然比較容易改啦,但是復雜的遞歸函數也能改嗎?

是的,所有的遞歸函數都能改寫成尾遞歸形式!具體數學證明在StackOverflow上有過回答,不過比較復雜,我這里也不照搬公式了。在實際的開發中,主要的指導思想是改寫成 CPS(Continuation-passing style,續文傳遞風格)的代碼風格。那什么又是 CPS 呢?答案又會是一大堆數學公式,我還是用一個簡單的例子說明一下吧。

我們計算二叉樹節點數,通常會使用深度優先(DFS)算法,先遞歸計算左右子樹的節點數,再返回整棵樹的節點:

function Count(root) {
  return DFS(root);

  function DFS(node) {
    if (!node) return 0;
    const left = DFS(node.left);
    const right = DFS(node.right);
    return left + right + 1;
  }
}

上面這個 DFS 方法顯然也不是尾遞歸函數,而且改寫尾遞歸還是挺難的:

  1. 計算完 DFS(node.left) 之后還要回頭執行 DFS(node.right)
  2. DFS(node.right) 執行后,再回到 DFS(node) 里計算前面兩個遞歸函數計算的結果

這個 DFS 不能像上面的 sum 一樣,能簡單地把上一步的結果存起來,因為有兩個遞歸函數要執行。那我們換一種形式,只存一個左子樹的結果,讓右子樹延后執行!

function Count(root) {
  return DFS(root, (ret) => ret);

  function DFS(node, next) {
    if (!node) return next(0);

    return DFS(node.left, (left) => {
      return DFS(node.right, (right) => {
        return next(left + right + 1);
      });
    });
  }
}

還是有點難度的吧?兩個 case 解釋一下:

  • 空樹

    空樹的話就是直接返回 next(0) 了, 這個 next = (ret) => ret,所以 DFS(null) 的結果就是 0

  • 單節點樹

         0
        / \
    null   null
    
    1. 第一個尾調簡化下來就是 return DFS(node.left, nextOfLeft);

    2. 由于 node.left 是空的,就直接跑 nextOfLeft(0),也即是 return DFS(node.right, nextOfRight);注意 nextOfRight = (right) => next(0+right+1),從閉包中可以獲得 left = 0

    3. node.right 也是空的,接著跑 nextOfRight(0),也就是 return next(0+0+1)

    4. 最后一個 next = (ret) => ret,也即返回 1

看懂了沒有?就是把右子樹的 DFS 操作寫成一個函數,當作參數傳給左子樹的 DFS;然后左子樹一路下去直到碰到空,再執行某右節點的遞歸操作。

歸納起來,CSP 風格就是函數多一個 callback 的回調函數;不同的 callback 達到的目的不同,但是最后的出口一定是第一個傳入的回調函數:

const fn = function (x, callback) {
  //...
  callback(x);
};

小結

本文介紹了尾調優化(TCO),以及根據尾調優化理論延伸出來的尾遞歸函數和續文傳遞風(CSP)。ES6 之后,主流的 JS 引擎都引入了 TCO 技術,主要的一個原因是缺乏 TCO 會導致一些 Javascript 算法因為害怕調用棧限制而降低了通過遞歸實現的概率。但是,運用 TCO 技術,需要將遞歸函數改寫成 CSP 風,這個需要一定的訓練,所以這個知識點一直比較小眾。本文最后部分稍微提了一下尾遞歸函數的寫法,我試著在 LeetCode 上也提交了幾個答案,確實可行;只不過很難用文字表達,這里也說明一下,解釋不清的地方還請大家諒解。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,431評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,637評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,555評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,900評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,629評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,976評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,976評論 3 448
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,139評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,686評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,411評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,641評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,129評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,820評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,233評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,567評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,362評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,604評論 2 380

推薦閱讀更多精彩內容