關於譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的樑柱;分享,是 CSS 裡最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。經過捶打磨練,成就了本書的中文版。本書包含了函數語言程式設計之精髓,希望可以幫助大家在學習函數語言程式設計的道路上走的更順暢。比心。
譯者團隊(排名不分先後):阿希、blueken、brucecham、cfanlife、dail、kyoko-df、l3ve、lilins、LittlePineapple、MatildaJin、冬青、pobusama、Cherry、蘿蔔、vavd317、vivaxy、萌萌、zhouyao
第 9 章:遞迴(下)
棧、堆
一起看下之前的兩個遞迴函式 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 個。如果第二個函式又呼叫了另外一個函式,堆疊幀將增加到 3 個,以此類推。“棧”的意思是,函式被它前一個函式呼叫時,這個函式幀會被“推”到最頂部。當這個函式呼叫結束後,它的幀會從堆疊中退出。
看下這段程式:
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 技術出現的早。追溯到上世紀 60 年代,當時開發人員想使用遞迴程式設計並希望執行在他們強大的計算機的裝置,而所謂強大計算機的記憶體,尚遠不如我們今天在手錶上的記憶體。
幸運的是,在那個希望的原野上,進行了一個有力的觀測。該技術稱為 尾呼叫。
它的思路是如果一個回撥從函式 baz()
轉到函式 bar()
時候,而回撥是在函式 baz()
的最底部執行 -- 也就是尾呼叫 -- 那麼 baz()
的堆疊幀就不再需要了。也就意謂著,記憶體可以被回收,或只需簡單的執行 bar()
函式。 如圖所示:
尾呼叫並不是遞迴特有的;它適用於任何函式呼叫。但是,在大多數情況下,你的手動非遞迴呼叫棧不太可能超過 10 級,因此尾呼叫對你程式記憶體的影響可能相當低。
在遞迴的情況下,尾呼叫作用很明顯,因為這意味著遞迴堆疊可以“永遠”執行下去,唯一的效能問題就是計算,而不再是固定的記憶體限制。在固定的記憶體中尾遞迴可以執行 O(1)
(常數階時間複雜度計算)。
這些技術通常被稱為尾呼叫優化(TCO),但重點在於從優化技術中,區分出在固定記憶體空間中檢測尾呼叫執行的能力。從技術上講,尾呼叫並不像大多數人所想的那樣,它們的執行速度可能比普通回撥還慢。TCO 是關於把尾呼叫更加高效執行的一些優化技術。
正確的尾呼叫 (PTC)
在 ES6 出來之前,JavaScript 對尾呼叫一直沒明確規定(也沒有禁用)。ES6 明確規定了 PTC 的特定形式,在 ES6 中,只要使用尾呼叫,就不會發生棧溢位。實際上這也就意味著,只要正確的使用 PTC,就不會丟擲 RangeError
這樣的異常錯誤。
首先,在 JavaScript 中應用 PTC,必須以嚴格模式書寫程式碼。如果你以前沒有用過嚴格模式,你得試著用用了。那麼,您,應該已經在使用嚴格模式了吧!?
其次,正確 的尾呼叫就像這個樣子:
return foo( .. );
複製程式碼
換句話說,函式呼叫應該放在最後一步去執行,並且不管返回什麼東東,都得有返回( return
)。這樣的話,JS 就不再需要當前的堆疊幀了。
下面這些 不能 稱之為 PTC:
foo();
return;
// 或
var x = foo( .. );
return x;
// 或
return 1 + foo( .. );
複製程式碼
注意: 一些 JS 引擎 能夠 把 var x = foo(); return x;
自動識別為 return foo();
,這樣也可以達到 PTC 的效果。但這畢竟不符合規範。
foo(..)
執行結束之後 1+
這部分才開始執行,所以此時的堆疊幀依然存在。
不過,下面這個 是 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)
執行結束之後,num1
與 sum(...nums)
的執行結果進行了累加。這樣的話,當其餘引數 ...nums
再次進行遞迴呼叫時候,為了得到其與 num1
累加的結果,必須要保留上一次遞迴呼叫的堆疊幀。
重構策略的關鍵點在於,我們可以通過把 置後 處理累加改為 提前 處理,來消除對堆疊的依賴,然後將該部分結果作為引數傳遞到遞迴呼叫。換句話說,我們不用在當前運用函式的堆疊幀中保留 num1 + sum(...num1)
的總和,而是把它傳遞到下一個遞迴的堆疊幀中,這樣就能釋放當前遞迴的堆疊幀。
開始之前,我們做些改動:把部分結果作為一個新的第一個引數傳入到函式 sum(..)
:
function sum(result,num1,...nums) {
// ..
}
複製程式碼
這次我們先把 result
和 num1
提前計算,然後把 result
作為引數一起傳入:
"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(..)
。我們可以把他們平級放置在立即執行的函式中,只暴露出我們想要的那個的函式:
"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 規範,又保證了 sum(..)
引數的整潔性,呼叫者不需要了解函式的內部實現細節。完美!
可是...天吶,本來是簡單的遞迴函式,現在卻出現了很多噪點。可讀性已經明顯降低。至少說,這是不成功的。有些時候,這只是我們能做的最好的。
幸運的事,在一些其它的例子中,比如上一個例子,有一個比較好的方式。一起重新看下:
"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
複製程式碼
帥呆了。比之前好了很多,嗯?!我認為這種模式在宣告/合理和執行之間達到了很好的平衡。
讓我們試著重構下前面的 maxEven(..)
(目前還不符合 PTC 規範)。就像之前我們把引數的和作為第一個引數一樣,我們可以依次減少列表中的數字,同時一直把遇到的最大偶數作為第一個引數。
為了清楚起見,我們可能使用演算法策略(類似於我們之前討論過的):
- 首先對前兩個引數
num1
和num2
進行對比。 - 如果
num1
是偶數,並且num1
大於num2
,num1
保持不變。 - 如果
num2
是偶數,把num2
賦值給num1
。 - 否則的話,
num1
等於undefined
。 - 如果除了這兩個引數之外,還存在其它引數
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 一詞通常用於表示在某個函式完成後指定需要執行的下一個步驟的回撥函式。組織程式碼,使得每個函式在其結束時接收另一個執行函式,被稱為後繼傳遞格式(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(..)
;記住,它只簡單的返回傳遞給它的任何東西。
更重要的是,這裡面增加了不僅僅是一個而是兩個後續函式。第一個後續函式接收 fib(n-2)
的執行結果作為引數 n2
。第二個內部後續函式接收 fib(n-1)
的執行結果作為引數 n1
。當得到 n1
和 n2
的值後,兩者再相加 (n2 + n1
),相加的執行結果會傳入到下一個後續函式 cont(..)
。
也許這將有助於我們梳理下流程:就像我們之前討論的,在遞迴堆疊之後,當我們傳遞部分結果而不是返回它們時,每一步都被包含在一個後續函式中,這拖慢了計算速度。這個技巧允許我們執行多個符合 PTC 規範的步驟。
在靜態語言中,CPS通常為尾呼叫提供了編譯器可以自動識別並重新排列遞迴程式碼以利用的機會。很可惜,不能用在原生 JS 上。
在 JavaScript 中,你得自己書寫出符合 CPS 格式的程式碼。這並不是明智的做法;以命令符號宣告的形式肯定會讓內容有些不清楚。 但總的來說,這種形式仍然要比 for
迴圈更具有宣告性。
警告: 我們需要注意的一個比較重要的事項是,在 CPS 中,建立額外的內部後續函式仍然消耗記憶體,但有些不同。並不是之前的堆疊幀累積,閉包只是消耗多餘的記憶體空間(一般情況下,是堆疊裡面的多餘記憶體空間)。在這些情況下,引擎似乎沒有啟動 RangeError
限制,但這並不意味著你的記憶體使用量是按比例固定好的。
彈簧床
除了 CPS 後續傳遞格式之外,另外一種記憶體優化的技術稱為彈簧床。在彈簧床格式的程式碼中,同樣的建立了類似 CPS 的後續函式,不同的是,它們沒有被傳遞,而是被簡單的返回了。
不再是函式呼叫另外的函式,堆疊的深度也不會大於一層,因為每個函式只會返回下一個將呼叫的函式。迴圈只是繼續執行每個返回的函式,直到再也沒有函式可執行。
彈簧床的優點之一是在非 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的優點是它們在宣告遞迴形式上的侵入性更小,由於你不必為了接收後續函式的引數而更改函式引數,所以除了執行和記憶體效能之外,彈簧床技術優於 CPS 的地方還有,它們在宣告遞迴形式上侵入性更小。雖然彈簧床技術並不是理想的,但它們可以有效地在命令迴圈程式碼和宣告性遞迴之間達到平衡。
總結
遞迴,是指函式遞迴呼叫自身。呃,這就是遞迴的定義。明白了吧!?
直遞迴是指對自身至少呼叫一次,直到滿足基本條件才能停止呼叫。多重遞迴(像二分遞迴)是指對自身進行多次呼叫。相互遞迴是當兩個或以上函式迴圈遞迴 相互 呼叫。而遞迴的優點是它更具宣告性,因此通常更易於閱讀。
遞迴的優點是它更具宣告性,因此通常更易於閱讀。缺點通常是效能方面,但是相比執行速度,更多的限制在於記憶體方面。
尾呼叫是通過減少或釋放堆疊幀來節約記憶體空間。要在 JavaScript 中實現尾呼叫 “優化”,需要基於嚴格模式和適當的尾呼叫( PTC )。我們也可以混合幾種技術來將非 PTC 遞迴函式重構為 PTC 格式,或者至少能通過平鋪堆疊來節約記憶體空間。
謹記:遞回應該使程式碼更容易讀懂。如果你誤用或濫用遞迴,程式碼的可讀性將會比命令形式更糟。千萬不要這樣做。
** 【上一章】翻譯連載 | 第 9 章:遞迴(上)-《JavaScript輕量級函數語言程式設計》 |《你不知道的JS》姊妹篇 **
iKcamp原創新書《移動Web前端高效開發實戰》已在亞馬遜、京東、噹噹開售。
iKcamp官網:www.ikcamp.com
2019年,iKcamp原創新書《Koa與Node.js開發實戰》已在京東、天貓、亞馬遜、噹噹開售啦!