10 1 1避免尾遞迴的堆疊溢位

2021-06-27 16:52:53 字數 3336 閱讀 9060

10.1.1避免尾遞迴的堆疊溢位

對於每乙個函式呼叫,執行時分配乙個棧幀(stack frame)。這些幀儲存在由系統維護的棧中;呼叫完成,棧幀被刪除;如果函式呼叫其他函式,那麼,乙個新的幀新增到這個棧的頂部。棧的大小是有限的,所以,太多的巢狀函式呼叫會耗光了給其他棧幀的空間,就不能再呼叫下乙個函式了。在 .net 中發生這種情況時,會引發 stackoverflowexception 錯誤;在 .net 2.0 以及更高版本中,此異常不能**獲,這將破壞整個過程。

遞迴是基於遞迴巢狀呼叫的,所以,寫複雜的遞迴計算時,經常遇到這樣的錯誤,並不奇怪。(這未必是真實的。在 c# 中,最常見的原因可能是屬性意外引用了自身,而不是其應指向的字段。我們忽略這樣的錯別字引起的意外,只考慮故意遞迴。)只是為了顯示我們討論的這種情況,我們使用第三章中列表彙總的**,但使用乙個很大的列表。

清單10.1 彙總列表和棧溢位 (f# interactive)

> let test1 = [ 1 .. 10000 ]    | 建立測試列表

let test2 = [ 1 .. 100000];;  |

val test1 : int list

val test2 : int list

> let rec sumlist(lst) =

match lst with

| -> 0    [1]

| hd::tl -> hd+ sumlist(tl);;    [2]

val sumlist : int list –> int

> sumlist(test1)    [3]

val it : int = 50005000

> sumlist(test2)    [4]

process is terminated due tostackoverflowexception.

就像每個遞迴函式一樣,sumlist 包含終止遞迴的分支[1],和遞迴呼叫自己的分支[2]。函式在執行遞迴呼叫前,完成一定數量的工作,(對列表執行模式匹配,讀取尾),然後,執行遞迴呼叫(對尾中的數字求和)。最後,用結果進行計算:把儲存在頭中的值和從遞迴返回的總和相加。最後一步的細節尤其重要,一會兒就能看到。

正如我們**的,存在**停止工作的乙個點,如果列表有幾萬個元素[3],能正常執行;列表有十萬個元素,由於遞迴太深,f# interactive 報告異常[4]。圖10.1顯示發生的情形:圖形上面的箭頭表示執行的第一部分,在遞迴呼叫之前和期間;圖形下面的箭頭表示遞迴返回的結果。

圖10.1 在計算列表中數字和時的棧幀情況。第一種情況,棧幀在限制之內,因此,操作成功;第二種情況,計算達到極限,並丟擲異常。

我們使用符號 [1..] 表示包含從 1 開始的乙個系列的列表。第一種情況,f# interactive 把從 1 到 10000 的列表作為sumlist 的引數值,並開始執行。該圖顯示了每次呼叫時,棧幀是如何加到棧中的。在這個過程中,每一步取出列表的尾,並用它作為引數值,遞迴呼叫 sumlist。第一種情況,棧足夠大,所以,最終會到達引數為空列表的情況;在第二種情況下,在大約 64000 次呼叫以後,就用完了所有的空間,執行時達到棧的極限,並引起 stackoverflowexception 異常。

無論從左到右的箭頭,還是回來的箭頭,都做了一些工作。第一部分操作,把列表分解成頭和尾兩部分,在遞迴呼叫之前執行;第二部分操作,把頭中的值加到總計中,在遞迴呼叫完成後執行。

現在我們已經知道失敗的原因了,那該如何做呢?基本思想是這樣的:只需要保持棧幀,因為需要在遞迴呼叫完成後,做一些工作。在示例中,仍然需要頭元素的值,所以,可以將它加到遞迴呼叫的結果中。如果函式在遞迴呼叫完成後,不需要做任何事情,可以從最後的遞迴呼叫直接跳回到呼叫者,在棧幀之間不使用任何值。我們使用下面的小函式演示一下:

let rec foo(arg) =

if (arg = 1000) then true

else foo(arg + 1)

可以看到,foo 函式在 else 分支執行的最後操作就是遞迴呼叫,它並不需要對結果做任何處理,直接返回結果。這種遞迴呼叫稱為尾遞迴(tail recursion)。實際上,遞迴最深層的結果是呼叫 foo(1000),可直接返回給呼叫者。

圖10.2 遞迴函式 foo 在遞迴呼叫後,不執行任何操作。執行可以直接跳轉到呼叫者(f# interactive),從最後的遞迴呼叫,即 foo(1000)。

在圖 10.2 中,可以看到,計算過程中建立的棧幀(從左邊到右的跳轉),在返回的跳轉上,不再使用。這樣,棧幀只在遞迴呼叫前需要,但是,當從 foo(1) 遞迴呼叫 foo(2) 時,就不需要foo(1) 的棧幀了,執行時簡單地把它扔掉,節省空間。圖 10.3 顯示了實際執行的尾遞迴函式 foo。

圖 10.3 尾遞迴函式的執行。在遞迴呼叫期間,棧幀可以丟棄,所以,在執行期間的任一點只需要一幀就夠了。

圖 10.3 顯示了 f# 如何執行尾遞迴函式。函式是尾遞迴,在棧上只需要乙個位置,這就使得遞迴版本像迭代求解一樣有效。

那麼,每個遞迴函式是否可以改寫成尾遞迴呢?答案是肯定的,但是,通常的方法有點複雜,我們將在 10.3 節討論。經驗法則是這樣的:如果函式在每個分支中只執行乙個遞迴呼叫,就應該能夠使用相對簡單的技巧。

.net 生態中的尾遞迴

編譯使用尾遞迴的函式時,f# 編譯器使用兩種方法。當函式呼叫自己(如前面的例子中 foo),把遞迴**翻譯成等價的、使用命令式迴圈的**。幾個函式彼此遞迴呼叫時,尾呼叫也會發生。在這種情況下,編譯器無法簡單地重寫**,並使用特殊的 tailcall 指令,它是直接由中間語言(intermediate language,il)支援的。

在除錯配置中,第二個優化預設情況下是關閉的,因為,它使除錯複雜化。尤其是,棧幀在尾呼叫期間被丟棄,這樣,在棧跟蹤視窗,就看不到它們。可以開啟此功能,在專案屬性中,選中「生成尾呼叫」。

由於尾呼叫是直接由中間語言支援的,c# 編譯器也可以識別尾遞迴呼叫,並利用這種優化。目前,不這樣做,因為c# 開發人員通常以命令式風格設計**,尾遞迴並不需要。

這並不是說,對於用 c# 寫的**,執行時不能使用尾呼叫優化。即使中間語言不包含希望使用尾呼叫的明確暗示,適時編譯器(just-in-time,jit)也會注意到,可以安全地做到,並繼續進行。發生這種情況的規則是複雜的,x86 和 x64 的適時編譯器之間是不同的,它們隨時會發生改變。在 .net4.0 中,適時編譯器在許多方面都有改進,因此,經常使用尾遞迴;另外,也從來沒有忽略 tailcall 指令,在 .net 2.0 中,這是偶然情況,特別是 x64 版本。

10 2 1 用尾遞迴避免棧溢位(續 )

10.2.1 用尾遞迴避免棧溢位 續 na ve 不像是英語,不知道什麼意思。第六章中的列表處理函式並不是尾遞迴。如果我們傳遞很大的列表,就會因棧溢位而失敗。我們將用尾遞迴重寫兩個函式 map 和 filter 將改正這個問題。為了對照,在清單 10.8 中包括了原來的實現。為了避免名字衝突,已經改...

go的尾遞迴

package main import fmt func recursive number int int return number recursive number 1 func main package main import fmt go 編譯器並不會對尾遞迴進行優化 func tailre...

騙人的尾遞迴

看函式式程式設計的資料看到這個概念,尾遞迴的定義 另外一片講概念的 看到最後那個快排的例子一頭霧水,然後發現有人和我有一樣的疑問,關於快排的尾遞迴優化的例子的問題 然後,摸魚半天後,再回來看這個問題,突然領悟到尾遞迴 是指那個while,而不是指while中的那個遞迴呼叫。經典 的 尾遞迴的例子,在...