感謝社區中各位的大力支持,譯者再次奉上一點點福利:阿里云產品券,享受所有官網優惠,并抽取幸運大獎:點擊這里領取
在下一頁,我們將進入遞歸的話題。
(本頁的剩余部分故意被留作空白)
<p> </p>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
<div style="page-break-after: always;"></div>
讓我們來談談遞歸。在深入之前,參見前一頁來了解其正式的定義。
很弱的玩笑,我知道。:)
遞歸是那些大多數開發者都承認其非常強大,但同時也不喜歡使用的技術之一。在這種意義上,我將之與正則表達式歸為同一范疇。強大,但令人糊涂,因此看起來 不值得花那么多力氣。
我是一個遞歸的狂熱愛好者,而且你也可以是!我在本章的目標就是說服你,遞歸是一種你應當帶入到你 FP 編碼方式中的重要工具。當使用得當的時候,遞歸對于復雜的問題是一種強大的聲明式編碼。
定義
遞歸是一個函數調用它自己,而且這個調用也做同樣的事情,這種循環一直持續到基準條件達成,然后所有的調用都被展開。
注意: 如果你不能確保基準條件會 最終 達成,那么遞歸就會永遠運行下去,并使你的程序崩潰或鎖住你的程序;搞對基準條件十分重要!
但是…… 這個定義的書面形式太過模糊。我們可以做得更好。考慮這個遞歸函數:
function foo(x) {
if (x < 5) return x;
return foo( x / 2 );
}
讓我們將調用 foo(16)
時這個函數發生的事情圖形化一下:
在第二步中,x / 2
產生 8
,它作為實際參數傳入遞歸的 foo(..)
調用。在第三步中,同樣的事情,x / 2
產生 4
,而它又作為實際參數被傳入另一個 foo(..)
調動。希望這部分看起來相當直截了當。
但一些人可能會被第四步發生的事情絆倒。一旦我們滿足了 x
(值 4
)< 5
的基準條件,我們就不再進行遞歸調用了,而僅僅(實際上)return 4
。特別是這幅圖中返回 4
的虛線簡化了那里發生的事情,所以讓我們深入這最后一步并將它圖形化為這三個子步驟:
一旦基準條件被滿足,這個返回值就逐一回溯調用棧中所有的調用(因此這些調用會 return
),最終 return
出最后的結果。
另一個遞歸的例子:
function isPrime(num,divisor = 2){
if (num < 2 || (num > 2 && num % divisor == 0)) {
return false;
}
if (divisor <= Math.sqrt( num )) {
return isPrime( num, divisor + 1 );
}
return true;
}
素數檢查的基本工作方式是,嘗試從整數 2
開始一直到被檢查的 num
的平方根,看它們中之一是否可以除盡(%
模運算返回 0
)這個數字。如果有,它就不是素數。否則,它一定是一個素數。divisor + 1
使用遞歸來迭代每一個可能的 divisor
值。
遞歸的最著名的例子之一就是計算斐波那契數列,這個數列被定義為:
fib( 0 ): 0
fib( 1 ): 1
fib( n ):
fib( n - 2 ) + fib( n - 1 )
注意: 這個數列的前幾個數字是:0、1、1、2、3、5、8、13、21、34、…… 每一個數字都是數列中前兩個數字的和。
用代碼直接表達斐波那契數列的定義:
function fib(n) {
if (n <= 1) return n;
return fib( n - 2 ) + fib( n - 1 );
}
fib(..)
遞歸地調用自己兩次,這通常被稱為二元遞歸。我們稍后將會更多地談到二元遞歸。
我們將在本章的各個地方使用 fib(..)
來展示關于遞歸的思想,但是這種特殊形式的一個缺點是存在很多重復工作。fib(n-1)
和 fib(n-2)
并不互相共享它們的任何工作成果,而是在整個整數遞減到 0
的過程中,幾乎完全互相重疊。
我們在第五章的“性能上的影響”一節中簡要地談到了默記。這里,默記將允許任何給定數字的 fib(..)
僅被計算一次,而不是重復計算許多次。我們不會在這個話題上走得太遠,但意識到性能上的問題對任何算法都很重要,不管是不是遞歸。
相互遞歸
當一個函數調用它自己時,被特別地稱為直接遞歸。這是我們在前一節的 foo(..)
、isPrime(..)
、和 fib(..)
中看到的。兩個或更多函數可以在一個遞歸周期中相互調動,這稱為相互遞歸。
這兩個函數就是相互遞歸的:
function isOdd(v) {
if (v === 0) return false;
return isEven( Math.abs( v ) - 1 );
}
function isEven(v) {
if (v === 0) return true;
return isOdd( Math.abs( v ) - 1 );
}
是的,這是一個計算數字奇偶性的笨辦法。但它展示了特定的算法可以根據互相遞歸進行定義的想法。
回憶一下前一節中的二元遞歸 fib(..)
;我們可以使用相互遞歸來表達它:
function fib_(n) {
if (n == 1) return 1;
else return fib( n - 2 );
}
function fib(n) {
if (n == 0) return 0;
else return fib( n - 1 ) + fib_( n );
}
注意: 這種相互遞歸的 fib(..)
實現摘自“使用相互遞歸的斐波那契數列”中的研究(https://www.researchgate.net/publication/246180510_Fibonacci_Numbers_Using_Mutual_Recursion)。
雖然這里展示的相互遞歸的例子非常造作,但是確實存在相互遞歸可能非常有用的更復雜的用例。
為什么要遞歸?
現在我們已經定義并展示了遞歸,我們應當檢視一下為什么遞歸如此有用。
最常為人所引用的理由是遞歸符合 FP 的精神,因為它用調用棧上的隱含狀態取代了(絕大多數)明確的狀態追蹤。遞歸通常在這樣的情況下最有用:當一個問題需要條件分支與回溯,而在一個純粹的迭代環境中管理這種狀態可能十分復雜;至少,這樣的代碼高度指令化而且很難閱讀與驗證。但在調用棧上將分支的每一層作為它自己的作用域進行追蹤,通常會顯著地提高代碼可讀性。
簡單的迭代算法可以很容易地表達為遞歸:
function sum(total,...nums) {
for (let i = 0; i < nums.length; i++) {
total = total + nums[i];
}
return total;
}
// vs
function sum(num1,...nums) {
if (nums.length == 0) return num1;
return num1 + sum( ...nums );
}
它不僅是調用棧取代并消滅了 for
循環,而且遞增的部分結果一直在調用棧的 return
中隱含地追蹤,而不是在每次迭代中給 total
重新賦值。FP 程序員經常喜歡在可能的地方避免對本地變量進行重新賦值。
在像這種求和的基本算法中,其區別是微小和微妙的。但是你的算法越精巧,你就越有可能看到遞歸取代指令式狀態追蹤的回報。
聲明式遞歸
數學家們使用 Σ 符號作為占位符來表示對一個數字列表的求和。他們這么做的主要原因是因為,如果他們在研究更復雜的公式時不得不手動寫出像 1 + 3 + 5 + 7 + 9 + ..
這樣的求和的話,就太麻煩了(也不易讀懂!)。使用符號就是聲明式的數學!
遞歸對算法的聲明性,與 Σ 對數學的聲明性的含義是相同的。遞歸表達的是一個問題的解決方案存在,但不必要求代碼的讀者理解這種解決方案是如何工作的。讓我們考慮兩種找出參數中最大偶數的方式:
function maxEven(...nums) {
var num = -Infinity;
for (let i = 0; i < nums.length; i++) {
if (nums[i] % 2 == 0 && nums[i] > num) {
num = nums[i];
}
}
if (num !== -Infinity) {
return num;
}
}
這個實現不是特別的難對付,但看懂它的微妙之處也不是很容易。maxEven()
、maxEven(1)
、和 maxEven(1,13)
都返回 undefined
這件事有多明顯?最后一個 if
語句為什么必要這件事很快就能搞明白嗎?
為了比較,讓我們來考慮一種遞歸的方式。我們可以將遞歸這樣符號化:
maxEven( nums ):
maxEven( nums.0, maxEven( ...nums.1 ) )
換言之,我們可以將一個數列中的最大偶數定義為,第一個數字與其余數字中的最大偶數相比之下的最大偶數。例如:
maxEven( 1, 10, 3, 2 ):
maxEven( 1, maxEven( 10, maxEven( 3, maxEven( 2 ) ) )
要在 JS 中實現這種遞歸定義,一個方式是:
function maxEven(num1,...restNums) {
var maxRest = restNums.length > 0 ?
maxEven( ...restNums ) :
undefined;
return (num1 % 2 != 0 || num1 < maxRest) ?
maxRest :
num1;
}
那么這種方式有什么好處?
首先,它的簽名與之前稍有不同。我有意地將第一個參數名稱叫做 num1
,將剩余的參數收集到 restNums
中。但為什么?我們本可以將它們全部收集到一個 nums
數組中,然后引用 nums[0]
。
這個函數簽名是對遞歸定義的一種有意提示。它讀起來就像這樣:
maxEven( num1, ...restNums ):
maxEven( num1, maxEven( ...restNums ) )
你看到簽名與遞歸定義之間的對稱性了嗎?
當我們能夠在函數簽名中使遞歸的定義更加明顯時,我們就增強了函數的聲明性。而且如果我們進而能夠將遞歸的定義投射到函數體中的話,它就會變得更好。
但我要說最明顯的改進是指令式 for
循環使人分心的地方被壓制了。這個循環的所有邏輯都被抽象到遞歸調用棧中,這樣這些東西就不會搞亂代碼。之后我們就可以將注意力集中到每次比較兩個數字并找出最大偶數的邏輯中 —— 總之是最重要的部分!
心理上,這里發生的事與一個數學家在一個很大的等式中使用 Σ 求和相似。我們在說,“這個列表中剩余部分的最大偶數是由 maxEven(...restNums)
計算的,所以我們假設這部分成立并繼續向下進行。”
另外,我們使用了 restNums.length > 0
守護條件來強化這個概念,因為如果沒有更多數字需要考慮了,那么自然的結果就是 maxRest
必定是 undefined
。我們不必再花費任何額外的精力來推理這一部分。基準條件(沒有更多數字要考慮了)是顯而易見的。
接下來,我們將注意力轉移至對照 maxRest
來檢查 num1
—— 這個算法的主邏輯是如何判定兩個數字中的哪一個是最大偶數。如果 num1
不是偶數(num1 % 2 != 0
),或者它小于 maxRest
,那么 maxRest
必須 被 return
,即使它是 undefined
。否則,num1
就是答案。
我在制造的情景是,與指令式的方式相比,它使閱讀一個實現時推理它變得更加直截了當,使我們分心的微小差別和噪音更少;它要比使用 -Infinity
的 for
循環方式聲明性更強。
提示: 我們應當指出除了手動迭代或遞歸之外的另一種(很可能是更好的)建模方式是第七章中討論的列表操作。數列可以首先被 filter(..)
為僅含有偶數,然后尋找最大值是一個 reduce(..)
,它簡單比較兩個數字并返回較大的一個。我們使用這個例子只是為了展示與手動迭代相比,遞歸更具聲明性的性質。
這是另一個遞歸的例子:計算二叉樹的深度。一個二叉樹的深度是樹中向下(要么在左側要么在右側)最長的節點路徑。另一種定義它的方法是遞歸的:一個樹在任意節點的深度,是 1(當前節點)加上它左側或右側子樹中深度較大的那一棵樹的深度。
depth( node ):
1 + max( depth( node.left ), depth( node.right ) )
將它直接翻譯為一個二元遞歸函數:
function depth(node) {
if (node) {
let depthLeft = depth( node.left );
let depthRight = depth( node.right );
return 1 + max( depthLeft, depthRight );
}
return 0;
}
我不會給出這個算法的指令式形式,但相信我,它要混亂得多而且指令性更強。這種遞歸的方式具有良好且優雅的聲明性。它緊貼著算法的遞歸定義而且很少有令人分心的事情。
不是所有的問題都是純粹遞歸的。它不是你應當廣泛應用的某種殺手锏。但遞歸可以很有效地將一個問題的表達從更強的指令性演化為更強的聲明性。
棧
讓我們重新審視早先的 isOdd(..)
/ isEven(..)
遞歸:
function isOdd(v) {
if (v === 0) return false;
return isEven( Math.abs( v ) - 1 );
}
function isEven(v) {
if (v === 0) return true;
return isOdd( Math.abs( v ) - 1 );
}
在大多數瀏覽器中,如果你試著運行這個你就會得到一個錯誤:
isOdd( 33333 ); // RangeError: Maximum call stack size exceeded
發生了什么錯誤?引擎拋出這個錯誤是因為它想防止你的程序耗盡系統內存。為了解釋這一切,我們需要看看當函數調用發生時,JS 引擎在背后發生了什么。
每一個函數調用都會留出一小塊稱為棧幀的內存。棧幀中持有一些特殊的重要信息:在一個函數中當前正在處理的語句的狀態,包括所有變量中的值。這些信息需要被存儲在內存(棧幀)中的原因是函數可能會調用另一個函數,這會暫停當前函數的運行。當另一個函數結束時,引擎需要從當前函數正好被暫停時的狀態繼續它的運行。
當第二個函數調用開始時,它也需要一個棧幀,從而將棧幀的數量增加到 2。如果這個函數再調用另一個函數,我們就需要第三個棧幀。以此類推。“棧” 這個詞說的是這樣的概念:每次一個函數被前一個函數調用時,下一個幀會被 壓入 棧的頂部。當一個函數調用完成時,它的幀會從棧中彈出。
考慮這段程序:
function foo() {
var z = "foo!";
}
function bar() {
var y = "bar!";
foo();
}
function baz() {
var x = "baz!";
bar();
}
baz();
一步一步地將這段程序的棧可視化:
注意: 如果這些函數沒有相互調用,而只是順序地被調用的話 —— 比如 baz(); bar(); foo();
,這樣每一個函數都會在下一個開始之前完成 —— 棧幀就不會堆積起來;每個函數調用都會在下一個棧幀加入棧中之前將自己的棧幀移除掉。
好了,那么每個函數調用都需要一點兒內存。在大多數普通程序的情況下沒什么大不了的,對吧?一旦當你引入遞歸后它很快就會成為一個大問題。雖然你幾乎絕對不會手動地在一個調用棧中將上千(甚至不會有上百個!)個不同的函數調用堆積在一起,但是你會很容易地看到有成千上萬的遞歸調用堆積起來。
成對的 isOdd(..)
/ isEven(..)
拋出一個 RangeError
是因為引擎遇到了一個被隨意設置的限制,從而認為調用棧增長的太大而需要被停止。這個限制不是一個基于實際內存水平接近于零,而是由引擎進行的預測:如果放任這種程序運行下去,內存就會耗盡了。知道或證明一個程序最終會停止是不可能的,所以引擎不得不進行一次有依據的猜測。
這種限制是依賴于實現的。語言規范中對此沒有任何說明,所以它不是 必須 的。但在實際中所有的 JS 引擎都確實有一個限制,因為不作限制將會制造出不穩定的設備,它們很容易受到爛代碼或惡意代碼的攻擊。在每一個不同設備環境中的每一個引擎都會強制一個它自己的限制,所以沒有辦法可以預測或保證我們可以在函數調用棧上走多遠。
這種限制對我們開發者的意義是,在解決關于大型數據集合的問題時遞歸的用途有一種應用的局限性。事實上,我認為這種局限性才是使遞歸在開發者工具箱中淪為二等公民的最大原因。令人遺憾的是,遞歸是一種事后思考而不是一種主要技術。
尾部調用
遞歸的出現遠早于 JS,這些內存的限制也是。早在 1960 年代,開發者們就因想使用遞歸而遭遇了設備內存限制的困難,而他們強大的計算機的內存比我們今天的手表還要小得多。
幸運的是,在那些早年間的日子里產生的一種強大的遠見依然給出了希望。這種技術稱為 尾部調用。
它的想法是,如果從函數 baz()
到函數 bar()
的調用發生在函數 baz()
執行的最末尾 —— 這稱為一個尾部調用 —— 那么 baz()
的棧幀就不再需要了。這意味著內存要么被回收,要么或者更好地,簡單地被重用于函數 bar()
的執行。圖形化一下的話:
尾部調用本質上和遞歸沒有直接的聯系;這個概念對任何函數調用都成立。但是你的手動非遞歸調用在大多數情況下不太可能超出 10 層的深度,所以尾部調用對你程序的內存使用空間造成明顯影響的可能性非常低。
尾部調用真正閃光的地方是在遞歸的情況下,因為它意味著一個遞歸棧可以 “永遠” 運行,而唯一需要關心的性能問題是計算,而不是固定的內存限制。尾部調用遞歸可以運行在固定為 O(1)
的內存用量中。
這種類型的技術經常被稱為尾部調用優化(TCO),但重要的是要區別檢測到尾部調用以便可以在固定的內存空間中運行的能力,與優化這種方式的技術。技術上講,尾部調用本身不是人們想象中的一種性能優化,因為它們實際上要比普通的調用運行得慢。TCO 才是由于優化尾部調用使之更高效運行的優化。
正確尾部調用(PTC)
在 ES6 之前,JavaScript 從沒要求(或禁止)過尾部調用。ES6 以一個語言規范的形式規定了尾部調用的識別,稱為正確尾部調用(PTC),而且保證 PTC 形式的代碼將可以不受內存棧增長的影響無邊界地運行。從實際上講,這意味著如果我們遵循 PTC,我們就不會得到 RangeError
。
首先,在 JavaScript 中 PTC 要求 strict 模式。你應當已經在使用 strict 模式了,但如果你沒有,這就是另一個你應當已經開始使用 strict 模式的理由。難道我沒提到過你應當已經在使用 strict 模式了嗎!?
第二,一個 正確 尾部調用是這種確切的形式:
return foo( .. );
換句話說,函數調用是這個函數最后一件需要執行的事情,而無論它返回什么值都要被明確地 return
。這樣,JS 就可以絕對地保證不再需要當前的棧幀了。
這些 不是 PTC:
foo();
return;
// 或者
var x = foo( .. );
return x;
// 或者
return 1 + foo( .. );
注意: 一個 JS 引擎 可能 會做一些代碼識別工作來認識到 var x = foo(); return x;
實質上與 return foo();
是一樣的,這將使它成為合法的 PTC。但語言規范中對此沒有要求。
1 +
的那一部分絕對會在 foo(..)
完成 之后 處理,所以棧幀不得不保留。
然而,這 是 PTC:
return x ? foo( .. ) : bar( .. );
在條件 x
計算之后,不是 foo(..)
就是 bar(..)
將會運行,而且在這兩種情況下,返回值都總是被 return
回去。這是 PTC 的形式。
二元遞歸 —— 兩個(或更多!)的遞歸調用被發起 —— 絕不可能按原樣就是有效的 PTC,因為所有的遞歸都不得不在尾部的位置以避免調用棧的增長。早先,我們展示了將二元遞歸重構為相互遞歸的例子。通過將多遞歸算法分割為隔離的函數調用 —— 每一個都分別表達為 PTC 形式 —— 來達成 PTC 是可能的。
重新安排遞歸
如果你想使用遞歸,但你的問題最終會增長到超過 JS 引擎的調用棧極限,那么你就需要重新安排你的遞歸調用來利用 PTC 的優勢(或者完全避免嵌套調用)。有幾種重構策略可以幫上忙,但自然有一些代價需要注意。
一句告誡,要時刻記住代碼可讀性是我們整體上最重要的目標。如果遞歸與這些后述的策略組合的結果是難以閱讀/理解的代碼,那么就不要使用遞歸;去尋找另一種可讀性更高的方式。
替換棧
遞歸的主要問題是內存用量,在一個函數調用發起下一個遞歸調用迭代時保留棧幀以追蹤它的狀態。如果我們能夠搞清如何重新安排遞歸的使用,以至于棧幀不再需要被保留,那么我們就可以使用 PTC 表達遞歸并利用 JS 引擎對尾部調用的優化處理。
讓我們回憶一下先前的求和的例子:
function sum(num1,...nums) {
if (nums.length == 0) return num1;
return num1 + sum( ...nums );
}
這不是 PTC 形式,因為在對 sum(...nums)
的遞歸調用完成之后,變量 total
被加到了它的結果上。所以,棧幀必須被保留,以便在其余的遞歸處理運行時追蹤這個 total
部分結果。
這種重構策略的關鍵特征是,我們可以通過 現在 就做加法而非 以后 再做,來移除我們對棧的依賴,然后將這個部分結果作為參數向下傳遞給遞歸調用。換句話說,與其將 total
保留在當前函數的棧幀中,不如將它推到下一個遞歸調用的棧幀中;這釋放了當前的棧幀,使得它可以被移除/重用。
為了開始,我們可以改變 sum(..)
函數的簽名,使它擁有一個新的作為部分結果的第一參數:
function sum(result,num1,...nums) {
// ..
}
現在,我們應當提前計算 result
和 num1
的加法,并將它傳遞出去:
"use strict";
function sum(result,num1,...nums) {
result = result + num1;
if (nums.length == 0) return result;
return sum( result, ...nums );
}
現在我們的 sum(..)
是 PTC 形式的了! 耶!
但缺點是現在我們改變了函數的簽名而讓它使用起來很奇怪。實質上調用方不得不將 0
在其余他希望求和的數字之前作為第一個參數傳遞。
sum( /*initialResult=*/0, 3, 1, 17, 94, 8 ); // 123
這很不幸。
通常人們會這樣解決這個問題:將他們帶有尷尬簽名的遞歸函數命名為不同的東西,然后定義一個接口函數來隱藏這種尷尬:
"use strict";
function sumRec(result,num1,...nums) {
result = result + num1;
if (nums.length == 0) return result;
return sumRec( result, ...nums );
}
function sum(...nums) {
return sumRec( /*initialResult=*/0, ...nums );
}
sum( 3, 1, 17, 94, 8 ); // 123
這好多了。但依然不幸的是我們現在創建了多個函數而不是一個。有時候你會看到一些開發者將遞歸函數作為一個內部函數“藏”起來,就像這樣:
"use strict";
function sum(...nums) {
return sumRec( /*initialResult=*/0, ...nums );
function sumRec(result,num1,...nums) {
result = result + num1;
if (nums.length == 0) return result;
return sumRec( result, ...nums );
}
}
sum( 3, 1, 17, 94, 8 ); // 123
這里的缺陷是我們將在每一次 sum(..)
被調用時重新創建那個內部的 sumRec(..)
函數。所以,我們可以回到它們是并排存在的函數時的狀態,但把它們藏在一個 IIFE 中,讓后僅僅暴露我們想要的哪一個:
"use strict";
var sum = (function IIFE(){
return function sum(...nums) {
return sumRec( /*initialResult=*/0, ...nums );
}
function sumRec(result,num1,...nums) {
result = result + num1;
if (nums.length == 0) return result;
return sumRec( result, ...nums );
}
})();
sum( 3, 1, 17, 94, 8 ); // 123
好的,我們得到了 PTC 而且我們得到了不要求調用方知道我們實現細節的漂亮干凈的簽名。耶!
但是…… 哇哦,我們簡單的遞歸函數現在多了不少噪音。可讀性絕對是降低了。至少這是很不幸的。但有時候,這是我們能做到的最佳狀態。
幸運的是,在一些其他情況下,比如當前這種,會有更好的方法。讓我們重置為這個版本:
"use strict";
function sum(result,num1,...nums) {
result = result + num1;
if (nums.length == 0) return result;
return sum( result, ...nums );
}
sum( /*initialResult=*/0, 3, 1, 17, 94, 8 ); // 123
你可能觀察到了,result
就像 num1
一樣是一個數字,這意味著我們總是可以將數列中的第一個數字視為我們運行中的和;這甚至包括第一個調用。我們所需的一切就是重命名這些參數使這一點更清晰:
"use strict";
function sum(num1,num2,...nums) {
num1 = num1 + num2;
if (nums.length == 0) return num1;
return sum( num1, ...nums );
}
sum( 3, 1, 17, 94, 8 ); // 123
贊。這好多了,不是嗎!?我認為這種模式在聲明性/合理性與性能之間取得了一個良好的平衡。
讓我們再一次嘗試 PTC 重構,回憶我們早先的 maxEven(..)
(目前還不是 PTC)。我們將看到它也很相似地將總和保持為第一個參數,我們可以每次縮減一個列表中的數字,讓第一個參數保持為我們目前為止最大的偶數。
為了清晰,我們可能用到的算法策略是(與我們之前討論過的相似):
- 一開始先比較頭兩個數字,
num1
和num2
。 -
num1
是偶數,而且num1
大于num2
嗎?如果是,保持num1
。 - 如果
num2
是偶數,保持它(存儲在num1
中)。 - 否則。退回到
undefined
(存儲在num1
中)。 - 如果有更多的
nums
要考慮,將它們遞歸地與num1
進行比較。 - 最后,返回留在
num1
中的任何值。
我們的代碼幾乎可以原樣跟隨這些步驟:
"use strict";
function maxEven(num1,num2,...nums) {
num1 =
(num1 % 2 == 0 && !(maxEven( num2 ) > num1)) ?
num1 :
(num2 % 2 == 0 ? num2 : undefined);
return nums.length == 0 ?
num1 :
maxEven( num1, ...nums )
}
注意: 第一個 maxEven(..)
調用不在 PTC 位置上,但因為它僅傳入了 num2
,所以它僅遞歸一層然后立即返回出來;這只是一個避免重復 %
邏輯的技巧。因此,這個調用不會增長遞歸棧,至少不會比這個調用是一個完全不同的函數的情況增長的多。第二個 maxEven(..)
調用是一個合法的遞歸調用,而且它確實位于 PTC 的位置上,這意味著我們的調用棧不會隨著遞歸處理增長。
應當再次強調的是,這個例子只是為了展示將遞歸轉換為 PTC 形式來優化調用棧(內存)使用的方式。表達一個最大偶數算法的更直接的方法可能確實是首先將 nums
數列過濾為僅含偶數,緊接著一個最大值冒泡或者一個排序。
不可否認,將遞歸重構為 PTC 對于簡單的聲明式形式有點兒侵入性,但它依然可以合理地完成任務。不幸的是,有些種類的遞歸即便使用接口函數也不能很好地工作,所以我們需要不同的策略。
延續傳遞風格(CPS)
在 JavaScript 中,延續(continuation) 一詞經常用于表示一個函數調用,它指定了一個特定函數完成其工作之后要執行的后續步驟。每個函數接收另一個函數在它的末尾執行,這種組織代碼的方式稱為延續傳遞風格(Continuation Passing Style —— CPS)。
有些形式的遞歸實際上不能被重構為純粹的 PTC,特別是多重遞歸。回憶一下早先的 fib(..)
函數,以及我們衍生出來的相互遞歸形式。在這兩種情況中存在多重遞歸調用,這實質上抵消了 PTC 內存優化。
然而,你可以進行第一個遞歸調用,而將后續的遞歸調用包裝在一個延續函數中傳遞給第一個調用。雖然這意味著最終在棧中有更多的函數需要被執行,但只要它們,包括延續,是 PTC 形式的,棧的內存用量就不會無界限地增長。
我們可以這樣處理 fib(..)
"use strict";
function fib(n,cont = identity) {
if (n <= 1) return cont( n );
return fib(
n - 2,
n2 => fib(
n - 1,
n1 => cont( n2 + n1 )
)
);
}
仔細注意這里發生了什么。首先,我們將延續函數 cont(..)
的默認值設置為我們第三章中的 identity(..)
工具;記住,它簡單地返回任何傳遞給它的東西。
另外,這里混入的不是一個而是兩個延續函數。第一個接收參數 n2
,它最終接收到值 fib(n-2)
的計算結果。下一個內部延續接收參數 n1
,它最終是 fib(n-1)
的值。一旦知道 n2
和 n1
的值,它們就可以相加在一起(n2 + n1
),而且這個值被傳遞給下一個 cont(..)
延續步驟。
也許這有助于在腦海中理清發生的事情:就像在前面的討論中,我們傳遞結果的一部分而非在遞歸棧堆積起來之后將其返回,我們在這里做的是相同的事情,但每一步都被包裝在一個延續中,這推遲了它的計算。這種技巧允許我們運行多個步驟,而每個步驟都是 PTC 形式。
在靜態語言中,CPS 經常是尾部調用的好機會,編譯器可以自動識別并重新安排遞歸代碼來利用它。不幸的是,這對 JS 的天性來說不成立。
在 JavaScript 中,你很可能需要自己編寫 CPS 形式。沒錯,這更笨重;像標記一樣的聲明式形式肯定被模糊了。但總體上,這種形式還是要比 for
循環的指令式實現更具聲明性。
警告: 在 CPS 中應當注意的一個主要問題是,創建額外的內部延續函數依然會消耗內存,但種類不同。與堆砌棧幀不同的是,閉包只會消耗自由內存(通常是堆)。引擎看起來不會在這樣的情況下實施 RangeError
的限制,但這不意味著你的內存用量是按比例固定的。
蹦床
CPS 創建延續并傳遞它們,另一種減輕內存壓力的技術稱為蹦床(trampolines)。在這種風格的代碼中,會創建 CPS 形式的延續,但它們不是被傳入,而是被淺層地返回。
與函數調用函數不同,這里的棧深度絕不會超過 1,因為每個函數都只是返回下一個應當被調用的函數。一個循環持續不斷地運行每一個被返回的函數,直到沒有函數可以運行。
蹦床的一個優點是你不被局限在支持 PTC 的環境下;另一個優點是每個函數調用都是普通的,不是 PTC 優化過的,所以它可能運行的快一些。
讓我們畫出 trampoline(..)
工具的草圖:
function trampoline(fn) {
return function trampolined(...args) {
var result = fn( ...args );
while (typeof result == "function") {
result = result();
}
return result;
};
}
只要有一個函數被返回,循環就持續運行,執行那個函數并捕獲它的返回值,然后檢查它的值。一旦一個非函數的值被返回,蹦床就會認為函數調用完成了,然后給出結果值。
因為每個延續都需要返回另一個延續,我們很可能需要使用一個之前的技巧:將結果的一部分作為參數向前傳遞。這是我們如何在早先數列求和的例子中使用這個工具:
var sum = trampoline(
function sum(num1,num2,...nums) {
num1 = num1 + num2;
if (nums.length == 0) return num1;
return () => sum( num1, ...nums );
}
);
var xs = [];
for (let i=0; i<20000; i++) {
xs.push( i );
}
sum( ...xs ); // 199990000
蹦床的缺點是它要求你將你的遞歸函數包裝在蹦床的驅動函數中;另外,就像 CPS,每個延續都會創建閉包。然而,與 CPS 不同的是,每一個延續函數都立即被執行并完成,所以雖然要解決的問題的棧深度耗盡了,引擎也不必累積增長的閉包內存。
除了執行與內存性能,蹦床超過 CPS 的優勢是對聲明式遞歸形式侵入性更小,這樣你就不必改變函數簽名來接收一個延續函數參數。蹦床不是理想的,但它們可能很有效地幫你在指令式循壞代碼和聲明式遞歸之間找到平衡。
總結
遞歸就是一個函數遞歸地調用它自己。哼。一個遞歸的遞歸定義。明白了!?
直接遞歸是一個函數至少發起對自己的調用一次,而這次調用持續地分發給自己直到滿足基準條件。多重遞歸(比如二元遞歸)是一個函數調用它自己多次。相互遞歸是當兩個或更多函數通過 互相地 調用對方遞歸地循環。
遞歸的好處是它更具聲明性而因此通常可讀性更強。缺點通常是性能,而在內存上受到的制約要比執行速度更甚。
尾部調用通過重用/丟棄棧幀緩和了內存的壓力。JavaScript 要求 strict 模式和正確尾部調用(PTC)來利用這種 “優化”。我們可以混合并調整使用幾種技術,通過將調用棧扁平化將一個非 PTC 遞歸函數重構為 PTC 形式,或者至少避免內存的制約。
記住:遞歸應當用于制造可讀性更好的代碼。如果你誤用或濫用遞歸,可讀性最后會變得比指令式形式更差。別這么做!