概述
尾調
在說尾調優化(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 也是對象)
我們還是以 foo
和 bar
為例,看看程序運行時的調用情況。
- 第一步自然是全局的初始化,將全局變量(
foo
和bar
)打包成一個棧幀放入調用棧中 - 程序掃描到
(A)
處時,bar(1)
被調用;程序進入bar
函數體內,返回地址(address A)、參數、局部變量等等組成新的棧幀,并放入調用棧頭部 - 程序繼續掃描到
(B)
處,foo(x)
被調用;程序進入foo
函數體內,返回地址(address B)、參數又成為新的棧幀放入調用棧頭部
之后的故事就是程序執行到(C)
處,返回結果;調用棧彈出棧幀,并根據棧幀內的返回地址一路回到(A)
處;最后程序結束。
尾調優化(TCO)
通常來說,調用棧的空間會有限制,也即棧幀的數量是有限的——幾千到幾萬不等;一旦超過這個限度,就會拋一個經典的錯誤——Stack overflow。向上面那樣簡單的代碼片段自然很難導致棧空間溢出啦,但是如果使用遞歸,幾萬個棧幀就不算個事了。
遞歸后文再講,我們再回到 foo
和 bar
的調用上。大家有沒有發現,上圖中 foo
函數的棧幀(綠色區域)并不是必須的,完全可以復用 bar
函數的棧幀(藍色區域):因為 bar
的計算邏輯已經結束了呀,留著也只是為了彈棧而已。
所謂的 TCO 就是做了這么個優化:當偵測到當前函數是尾調用時,就復用之前的棧幀。如下圖所示,通過 TCO 優化,我們就節省了一個棧幀的空間。如果尾調的數量有成千上萬個的話,TCO 就可以很好的避免 Stack Overflow 了。
尾遞歸函數
書接上文,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 方法顯然也不是尾遞歸函數,而且改寫尾遞歸還是挺難的:
- 計算完
DFS(node.left)
之后還要回頭執行DFS(node.right)
-
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
第一個尾調簡化下來就是 return
DFS(node.left, nextOfLeft);
由于
node.left
是空的,就直接跑nextOfLeft(0)
,也即是 returnDFS(node.right, nextOfRight)
;注意nextOfRight = (right) => next(0+right+1)
,從閉包中可以獲得left = 0
node.right
也是空的,接著跑nextOfRight(0)
,也就是 returnnext(0+0+1)
最后一個
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 上也提交了幾個答案,確實可行;只不過很難用文字表達,這里也說明一下,解釋不清的地方還請大家諒解。