遞歸棧溢出
Python的遞歸調用棧的深度有限制,默認深度為998,可以通過sys.getrecursionlimit()查看。
針對遞歸棧溢出,我們可以將默認深度設置為大一些,這樣不會報錯,但是再大的深度總歸是有限的,而且深度越大對內存的占用也就越大,這對我們的程序是不利的。所以一般情況下我們不要將棧的深度設定太大。
但有時候我們又需要無限但遞歸,這里我們就可以用到尾遞歸。
尾遞歸
尾遞歸在很多語言中都可以被編譯器優化, 基本都是直接復用舊的執行棧, 不用再創建新的棧幀, 原理上其實也很簡單, 因為尾遞歸在本質上看的話遞歸調用是整個子過程調用的最后執行語句, 所以之前的棧幀的內容已經不再需要, 完全可以被復用。
需要注意的是, 一定記住尾遞歸的特點: 遞歸調用是整個子過程調用的最后一步,return的時候不能出現計算。否則就不是真正的尾遞歸了, 如下就不是真正的尾遞歸, 雖然遞歸調用出現在尾部
def fib(n): if n == 0: return 0 elif n == 1: return 1 else: return fib(n-1) + fib(n-2)
很明顯遞歸調用並不是整個計算過程的最后一步, 計算fib(n)是需要先遞歸求得fib(n-1)和fib(n-2), 然后做一步加法才能得到最終的結果。
如下是尾遞歸
def fib(n, a, b): if n == 1: return a else: return fib(n-1, b, a+b)
然而!!!Python語言的編譯器是不支持尾遞歸的!!!
上面那串紅字是什么意思呢,即使你用了尾遞歸的語法寫了一串遞歸的代碼,但是最后還是會報深度問題的錯,因為python的源碼中並沒有集成對尾遞歸的支持。。。
怎么辦呢?有幾種解決辦法:
- 修改源碼,如果你不怕會有后續錯誤的話。。
- 將上述代碼最后的return改為yield,然后在調用的時候用next,利用生成器實現。(會出一個問題,如果遞歸的函數需要傳參,而參數是會變化的話,你會發現每次調用參數都不會變。。)
- 往下看~~
關於Python中的尾遞歸調用有一段神奇的代碼:
import sys class TailCallException(BaseException): def __init__(self, args, kwargs): self.args = args self.kwargs = kwargs def tail_call_optimized(func): def _wrapper(*args, **kwargs): f = sys._getframe() if f.f_back and f.f_back.f_back and f.f_code == f.f_back.f_back.f_code: raise TailCallException(args, kwargs) else: while True: try: return func(*args, **kwargs) except TailCallException, e: args = e.args kwargs = e.kwargs return _wrapper @tail_call_optimized def fib(n, a, b): if n == 1: return a else: return fib(n-1, b, a+b) r = fib(1200, 0, 1) #不報錯!突破了調用棧的深度限制!只要加上裝飾器,尾遞歸就實現了!!
嗯,沒錯,就是這么簡單。以后要想實現尾遞歸都時候就復制上面裝飾器以上都代碼,然后將遞歸函數加上該裝飾器就OK。
以上的代碼是怎樣的工作的呢?
理解它需要對Python虛擬機的函數調用有一定的理解。其實以上代碼和其他語言對尾遞歸的調用的優化原理都是相似的,那就是在尾遞歸調用的時候重復使用舊的棧幀, 因為之前說過, 尾遞歸本身在調用過程中, 舊的棧幀里面那些內容已經沒有用了, 所以可以被復用。
Python的函數調用首先要了解code object、function object、frame object這三個object(對象),
- code object是靜態的概念, 是對一個可執行的代碼塊的抽象, module, function, class等等都會被生成code object, 這個對象的屬性包含了”編譯器”(Python是解釋型的,此處的編譯器准確來說只是編譯生成字節碼的)對代碼的靜態分析的結果, 包含字節碼指令, 常量表, 符號表等等。
- function object是函數對象, 函數是第一類對象, 說的就是這個對象。當解釋器執行到def fib(...)語句的時候(MAKE_FUNCTION), 就會基於code object生成對應的function object。但是生成function object並沒有執行它, 當真正執行函數調用的時候, fib(...)這時候對應的字節碼指令(CALL_FUNCITON), 可以看一下, CPython的源碼, 真正執行的時候Python虛擬機會模擬x86CPU執行指令的大致結構, 而運行時棧幀的抽象就是frame obejct, 這玩意兒就模擬了類似C里面運行時棧, 寄存器等等運行時狀態, 當函數內部又有函數調用的時候, 則又會針對內部的嵌套的函數調用生成對應的frame object, 這樣看上去整個虛擬機就是一個棧幀連着又一個棧幀, 類似一個鏈表, 當前棧幀通過f_back這個指針指向上一棧幀, 這樣你才能在執行完畢, 退出當前幀的時候回退到上一幀。和C里執行棧的增長退出模式很像。
- frame object棧幀對象只有在當前函數執行的時候才會產生, 所以你只能在函數內通過sys._getframe()調用來獲取當前執行幀對象。通過f.f_back獲取上一幀, f.f_back.f_back來獲取當前幀的上一幀的上一幀(當前幀的“爺爺”)。
另外一個需要注意到的是, 對於任何對尾遞歸而言, 其執行過程可以線性展開, 此時你會發現, 最終結果的產生完全可以從任意中間狀態開始計算, 最終都能得到同樣的執行結果。如果把函數參數看作狀態(state_N)的話, 也就是tail_call(state_N)->tail_call(state_N-1)->tail_call(state_N-2)->...->tail_call(state_0), state_0是遞歸臨界條件, 也就是遞歸收斂的最終狀態, 而你在執行過程中, 從任一起始狀態(state_N)到收斂狀態(state_0)的中間狀態state_x開始遞歸, 都可以得到同樣的結果。
當Python執行過程中發生異常(錯誤)時(或者也可以直接手動拋出raise ...), 該異常會從當前棧幀開始向舊的執行棧幀傳遞, 直到有一個舊的棧幀捕獲這個異常, 而該棧幀之后(比它更新的棧幀)的棧幀就被回收了。
有了以上的理論基礎, 就能理解之前代碼的邏輯了:
-
尾遞歸函數fib被tail_call_optimized裝飾, 則fib這個名字實際所指的function object變成了tail_call_optimized里return的_wrapper, fib 指向_wrapper。
-
注意_wrapper里return func(*args, **kwargs)這句, 這個func還是未被tail_call_optimized裝飾的fib(裝飾器的基本原理), func是實際的fib, 我們稱之為real_fib。
-
當執行fib(1200, 0, 1)時, 實際是執行_wrapper的邏輯, 獲取幀對象也是_wrapper對應的, 我們稱之為frame_wapper。
-
由於我們是第一次調用, 所以”if f.f_back and f.f_back.f_back and f.f_code == f.f_back.f_back.f_code”這句里f.f_code==f.f_back.f_back.f_code顯然不滿足。
-
繼續走循環, 內部調用func(*args, **kwargs), 之前說過這個func是沒被裝飾器裝飾的fib, 也就是real_fib。
-
由於是函數調用, 所以虛擬機會創建real_fib的棧幀, 我們稱之為frame_real_fib, 然后執行real_fib里的代碼, 此時當前線程內的棧幀鏈表按從舊到新依次為: 舊的虛擬機棧幀,frame_wrapper,frame_real_fib(當前執行幀)
real_fib里的邏輯會走return fib(n-1, b, a+b), 有一個嵌套調用, 此時的fib是誰呢?此時的fib就是我們的_wrapper, 因為我們第一步說過, fib這個名字已經指向了_wrapper這個函數對象。
-
依然是函數調用的一套, 創建執行棧幀, 我們稱之為frame_wrapper2, 注意: 執行棧幀是動態生成的, 雖然對應的是同樣函數對象(_wrapper), 但依然是不同的棧幀對象, 所以稱之為frame_wrapper2。 今后進入frame_wrapper2執行, 注意此時的虛擬機的運行時棧幀的結構按從舊到新為:
舊的虛擬機棧幀、frame_wrapper、frame_real_fib、frame_wrapper2(當前執行棧幀) -
進入frame_wrapper2執行后, 首先獲取當前執行幀, 即frame_wrapper2, 緊接着, 執行判斷, 此時:
if f.f_back and f.f_back.f_back and f.f_code == f.f_back.f_back.f_code
以上這句就滿足了, f.f_code是當前幀frame_wrapper2的執行幀的code對象, f.f_back.f_back.f_code從當前的執行幀鏈表來看是frame_wrapper的執行幀的code對象, 很顯然他們都是同一個code塊的code object(def _wrapper…..)。於是拋出異常, 通過異常的方式, 把傳過來的參數保留, 然后, 異常向舊的棧幀傳遞, 直到被捕獲, 而之后的棧幀被回收, 即拋出異常后, 直到被捕獲時, 虛擬機內的執行幀是:舊的虛擬機棧幀、frame_wrapper(當前執行幀)
於是現在恢復執行frame_wrapper這個幀, 直接順序執行了, 由於是個循環, 同時參數通過異常的方式被捕獲, 所以又進入了return func(*args, **kwargs)這句, 根據我們之前說的, 尾遞歸從遞歸過程中任意中間狀態都可以收斂到最終狀態, 所以就這樣, 執行兩個幀, 搞出中間狀態, 然后拋異常, 回收兩個幀, 這樣一直循環直到求出最終結果。
在整個遞歸過程中, 沒有頻繁的遞歸一次, 生成一個幀, 如果你不用這個優化, 可能你遞歸1000次, 就要生成1000個棧幀, 一旦達到遞歸棧的深度限制, 就掛了。
使用了這個裝飾器之后, 最多生成3個幀, 隨后就被回收了, 所以是不可能達到遞歸棧的深度的限制的。
注意: 這個裝飾器只能針對尾遞歸使用。