JavaScript中的遞歸、PTC、TCO和STC

近來,好像大家都對函數式編程及其概念非常感興趣。可是,很多人不談遞歸,特別是不談PTC(Proper Tail Call,適當的尾調用)。而這纔是編寫清晰簡潔代碼,同時又不導致棧溢出的關鍵。
本文將通過圖示的方法討論遞歸,討論什麼是PTC、TCO(Tail Call Optimization,尾調用優化)、STC(Syntactic Tail Call,語法級尾調用),以及它們的區別、原理,還會討論主流JavaScript引擎對它們的實現。
遞歸
遞歸出現在某個問題的解決方案依賴於對其別的實例應用同樣的解決方案之時。
比如,4的factorial(階乘)可以定義爲3的factorial乘以4。
這意味着一個數的階乘可以通過它自己來定義:
簡言之,在函數調用自身時,我們說就用到了遞歸。
理解遞歸
說到理解遞歸,我比較喜歡想象從首次執行衍生出多個執行分支,然後這些分支的執行結果再“冒泡”回到根調用。
以前面計算階乘爲例,第一次調用派生出多個調用,直到派生出本身存在定義的調用爲止(具體來說,就是到調用0的階乘爲止,因爲根據定義0的階乘爲1)。然後,這個定義的結果立即返回(冒泡),以便基於這個結果執行另一個操作並再次返回值。之後這個過程重複進行,直到把最終結果返回給“根”調用。
如果用圖示方式可視化地展示以5爲參數調用factorial函數,那麼可以這樣表示:
與編譯器理論相比較,這個過程非常像使用上下文無關語法取得句子,直至遇到終點值。
乍一看還挺抽象,那我們就換一種方式來說明一下,這次以計算N個數的Fibonnacci Sequence(斐波納契數列)爲例。
這是Fibonacci函數的代碼:
簡單地說,每次調用Fibonacci函數都會派生兩次新調用,新調用同樣調用自身,直到參數變成一個小於2的數(因爲此時的斐波納契數列從1和1相加開始,結果爲2)。
在參數小於2時,直接返回結果給上級調用,然後上級調用再逐級將結果冒泡返回給根調用。
如下圖所示,調用fibonacci(4)會派生多次調用,直到調用能夠直接返回結果(“既定方案”),在這裏就是Fibonacci數列的前兩個數:1(fibonacci(1))和1(fibonacci(0))。
由於每次遞歸調用都依賴於另外兩次遞歸調用(除非參數小於2直接返回既定結果),因此我們從葉節點(1)開始返回值,然後對兩次遞歸調用的結果求和,再把結果返回給上級調用。
如上面的例子所示,遞歸有線性遞歸和分支遞歸之分。線性遞歸,就是遞歸調用只有一個分支,就像計算階乘那樣。分支遞歸,就是遞歸調用不止一個分支,像計算斐波納契數列那樣。
說到遞歸,主要應該考慮兩點:
定義退出條件,也就是自身即結果的原子級定義(也叫“既定結果”)。定義算法的哪個部分是可遞歸的
定義了退出條件後,就可以輕鬆確定什麼情況下函數還要再調用自己,什麼情況下可以直接使用現成的結果。
如果想了解更多關於遞歸的實踐和有趣應用,請參考樹和圖相關算法的工作原理。
遞歸與調用棧
通常,在使用遞歸的時候,一般都會產生一個函數調用棧,其中每個函數都需要使用前一次自我調用的結果。
爲說明使用遞歸時的調用棧是什麼樣的,我們以簡單的factorial函數作例子。
以下是它的代碼:
接下來,我們調用它看看3的階乘。
通過前面的例子我們知道,3的階乘要計算factorial(2)、factorial(1)和factorial(0)並將它們的結果相乘。這意味着,要計算3的階乘,需要額外調用3次factorial函數。
以上每次調用都會把一個新的棧幀推到調用棧上,而所有調用都進棧後的結果大致如下:
現在,我們添加對console.trace的調用,以便調用factorial函數時在調用棧中看到當前的棧幀。
更改後的代碼應該是這樣的:
下面我們就來運行代碼,分析打印出的每一段調用棧信息。
這是第一段:
看到了吧,第一個調用棧只包含對factorial函數的第一次調用,也就是factorial(3)。接下來就有意思了:
這次我們在上一次調用基礎上又調用了factorial函數。這個調用是factorial(2)。
這是調用factorial(1)時的棧:
我們看到,這在之前調用的基礎上又增加了一次調用。
最後是調用factorial(0)時的調用棧:
正像我在本節開始時說的一樣,調用factorial(3)需要進一步調用factorial(2)、factorial(1)和factorial(0)。這就是factorial函數在調用棧中現身4次的原因。
適當的尾調用(PTC)
ES6出來後應該會實現適當的尾調用,但是由於我將在本文後面解釋的原因,所有主要的JS引擎目前都沒有實現。
適當的尾調用可以避免遞歸調用時的棧膨脹。不過,爲了做到適當的尾調用,我們首先得有一個尾調用。
那什麼是尾調用?
尾調用是執行時不會造成棧膨脹的函數。尾調用是執行return之前要做的最後一個操作,而這個被調用函數的返回值由調用它的函數返回。調用函數不能是生成器函數。
爲了演示適當的尾調用如何起作用,我們需要重構factorial函數,實現尾遞歸:
好了,現在這個函數要做的最後一件事就是返回調用自身的結果,這就是尾調用。
大家可能注意到了,這次我們給函數傳遞了兩個參數:一個是我們想要計算下一個階乘的數值(n - 1),一個是累積的總數,即n * total。
現在,我們不一定需要(像前面例子中那樣)先取得派生調用的葉節點了。因爲我們有了求解當前問題所需的所有值(累積的值,以及下一次應該計算的階乘)。
我們來分析一下,爲什麼這個函數可以在不依賴多次遞歸調用的情況下完成計算。
以下是調用factorial(4)的過程。
在棧頂部壓入一個對factorial的調用。因爲不是0(既定情況),那麼我們知道下一次要計算的值()和當前累積值(4 * total)。再次調用factorial,它會得到完成計算所需的所有數據:要計算的下一個階乘和累積的總數。至此,不再需要之前的棧幀了,可以把它彈出,只添加新的調用factorial(3, 4)。這次調用同樣大於0,於是需要計算下一個數的階乘,同時將累積值()與當前值()相乘。至此(又)不再需要上一次調用了,可以把它彈出,再次調用factorial並傳入和12。再次更新累積值爲24,同時計算的階乘。前一幀又從棧中被刪除,我們又用乘以24(總數),並計算的階乘。最後,的階乘返回了累積的總數,也就是24(就是4的階乘)。
簡單說吧,這就是整個過程:
現在,不需要在棧中保留n個幀,而只要保留1個即可。因爲後續調用並不依賴之前的調用。結果就是新factorial函數的內存複雜變由O(N)變成了O(1)。
在Node中使用適當的尾調用
給上面的函數添加一行console.trace調用,並且調用factorial(3)以便看到棧中的調用情況:
你會發現,雖然這個函數已經是尾遞歸的了,但棧中仍然保存了多次對factorial函數的調用:
爲了在Node中使用適當的尾調用,必須在JS文件頂部添加'use strict'以啓用strict mode,然後以--harmony_tailcalls標記來運行。
爲了讓以上標記能改進factorial函數,我們的腳本應該是這樣的:
下面這樣運行:
再次運行後,得到如下棧跟蹤信息:
如你所見,每次棧中保存的對factorial的調用只有一個了。因爲每次調用這個函數後,之前的調用幀就沒用了。
因此說到如何創建尾調用函數,關鍵就在於傳遞下一次調用所需的全部“狀態”,這樣才能達到刪除下一幀的目的。有時候在一個函數裏可能無法做到這一點,此時可以考慮利用嵌套函數實現尾遞歸。
要記住,適當的尾調用不一定會讓代碼跑得更快。實際上,多數情況下,有了它反而會更慢。
然而,使用適當的尾調用除了可以節省調用棧的內存佔用,還會由於在局部聲明的對象使運行遞歸函數佔用的內存更少。由於下一次遞歸調用不必使用當前幀中的任何變量,因此垃圾收集器就可以把當前幀中的所有對象銷燬了。但在“非尾遞歸”函數中,每調用一次遞歸函數,都要分配一次內存。畢竟所有幀在最後一次遞歸調用(即返回“既定情況”的調用)返回前,都必須保存在幀裏。
尾調用優化(TCO)
與適當的尾調用不同,尾調用優化的目的則是提升尾遞歸函數的性能,讓它們跑得更快。
尾調用優化是編譯器使用的一種技術,它使用jumps把遞歸調用轉換成一個循環。
我們已經知道了尾遞歸函數的執行過程,那麼在此基礎上解釋尾調用優化也就簡單了。
仍然以前面的factorial函數爲例,我們來看看假如我們的JavaScript引擎啓用了尾調用優化會怎麼樣。
以下是起始代碼:
既然以上代碼在退出條件(“既定情況”)滿足前會重複執行,那我們何不把重複的代碼裝到一個標籤裏,直接來回跳轉,從而避免重複調用自己呢?好,實現以上想法的代碼如下:
這說明尾調用優化與實現適當的尾調用不是一回事!
實現適當的尾調用及尾調用優化的缺點
在前面的例子中我們看到,適當的尾調用意味着不會在棧裏“保存”調用函數的歷史。結果查看棧追蹤信息就很難定位到問題,因爲它並不包含所有調用信息,怎麼知道是哪次調用導致出錯?
前面提到的文章中涉及的console.trace語句和Error.stack屬性都會因此受影響。
對此,可以通過在開發環境中使用“影子棧”(Shadow Stack)來解決。
影子棧就是“備份棧”。雖然正常的棧在適當的尾調用下不會保存所有幀,但所有調用都可推入這個“影子棧”,以便利用它進行調試,同時還不會污染執行棧。
然而,可以想見,目前這方面還沒有可靠易用的工具。而且,這樣一來又要在另一個地方佔用更多內存以保存所有幀(開發環境下應該不是問題)。
還有,對於實現尾調用優化的代碼,影子棧也解決不了Error.stack屬性的問題。因爲此時將使用goto語句,不會再向棧追蹤信息中添加任何幀信息。這意味着假如有錯誤對象被創建,那麼產生這個錯誤的函數可能並不在棧裏。因爲我們是直接在函數內跳轉,而不是像遞歸那樣重複調用函數。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章