[英]Does the continuation + tail recursion trick actually trade stack space for heap space?
在函數式編程中有這種CPS技巧,它采用非尾遞歸函數並在連續傳遞樣式(CPS)中重寫它,從而使其尾遞歸。 很多問題實際上涵蓋了這一點,比如
舉一些例子
let rec count n =
if n = 0
then 0
else 1 + count (n - 1)
let rec countCPS n cont =
if n = 0
then cont 0
else countCPS (n - 1) (fun ret -> cont (ret + 1))
count
的第一個版本將在每次遞歸調用中累積堆棧幀,在我的計算機上產生大約n = 60000
的堆棧溢出。
CPS技巧的想法是countCPS
實現是尾遞歸的,因此計算
let f = countCPS 60000
實際上將被優化為循環運行並且沒有問題地工作。 而不是堆棧幀,繼續運行將在每一步中累積,但這是堆上的誠實對象,其中內存不會導致問題。 因此CPS風格據說可以用於堆空間的堆棧空間。 但我懷疑它甚至做到了這一點。
原因如下:通過實際運行延續作為countCPS 60000 (fun x -> x)
評估計算會打擊我的堆棧! 每次通話
countCPS (n - 1) (fun ret -> cont (ret + 1))
從舊的生成一個新的延續閉包,並運行它涉及一個函數應用程序。 所以在評估countCPS 60000 (fun x -> x)
,我們調用一個60000閉包的嵌套序列,即使它們的數據位於堆上,我們也有功能應用程序,所以再次有堆棧幀。
讓我們深入研究生成的代碼,反匯編成C#
對於countCPS
,我們得到
public static a countCPS<a>(int n, FSharpFunc<int, a> cont)
{
while (n != 0)
{
int arg_1B_0 = n - 1;
cont = new Program<a>.countCPS@10(cont);
n = arg_1B_0;
}
return cont.Invoke(0);
}
我們去了,尾遞歸實際上已經被優化了。 但是,閉包類看起來像
internal class countCPS@10<a> : FSharpFunc<int, a>
{
public FSharpFunc<int, a> cont;
internal countCPS@10(FSharpFunc<int, a> cont)
{
this.cont = cont;
}
public override a Invoke(int ret)
{
return this.cont.Invoke(ret + 1);
}
}
所以運行最外面的閉包將導致它.Invoke
它的子閉包,然后它一次又一次地關閉子... 我們真的再次有60000次嵌套函數調用。
所以我不知道延續技巧是如何實際做出廣告的。
現在我們可以爭辯說this.cont.Invoke
再次是一個尾調用,因此它不需要堆棧幀。 .NET是否執行這種優化? 更復雜的例子如何呢?
let rec fib_cps n k = match n with
| 0 | 1 -> k 1
| n -> fib_cps (n-1) (fun a -> fib_cps (n-2) (fun b -> k (a+b)))
至少我們必須爭論為什么我們可以優化在延續中捕獲的嵌套函數調用。
interface FSharpFunc<A, B>
{
B Invoke(A arg);
}
class Closure<A> : FSharpFunc<int, A>
{
public FSharpFunc<int, A> cont;
public Closure(FSharpFunc<int, A> cont)
{
this.cont = cont;
}
public A Invoke(int arg)
{
return cont.Invoke(arg + 1);
}
}
class Identity<A> : FSharpFunc<A, A>
{
public A Invoke(A arg)
{
return arg;
}
}
static void Main(string[] args)
{
FSharpFunc<int, int> computation = new Identity<int>();
for(int n = 10; n > 0; --n)
computation = new Closure<int>(computation);
Console.WriteLine(computation.Invoke(0));
}
更准確地說,我們模擬了CPS樣式函數在C#中構建的閉包。
很明顯,數據存在於堆上。 但是, computation.Invoke(0)
導致嵌套的Invoke
s級聯到子閉包。 只需在Identity.Invoke
上設置一個斷點,然后查看堆棧跟蹤! 那么,如果它實際上大量使用兩者,那么構建計算如何交換堆棧用於堆空間呢?
這里有很多概念。
對於尾遞歸函數,編譯器可以將其優化為循環,因此不需要任何堆棧或堆空間。 您可以通過編寫以下內容將count
函數重寫為簡單的尾遞歸函數:
let rec count acc n =
if n = 0
then acc
else count (acc + 1) (n - 1)
這將被編譯成一個帶有while
循環的方法,該循環不進行遞歸調用。
當函數不能被寫為尾遞歸時,通常需要繼續。 然后,你需要保留一些國家無論是在棧或堆上。 無視可以更有效地寫入fib
的事實,天真的遞歸實現將是:
let fib n =
if n <= 1 then 1
else (fib (n-1)) + (fib (n-2))
這需要堆棧空間來記住第一次遞歸調用返回結果后需要發生的事情(然后我們需要調用另一個遞歸調用並添加結果)。 使用continuation,您可以將其轉換為堆分配的函數:
let fib n cont =
if n <= 1 then cont 1
else fib (n-1) (fun r1 ->
fib (n-2) (fun r2 -> cont (r1 + r2))
這為每個遞歸調用分配一個continuation(函數值),但它是尾遞歸的,所以它不會耗盡可用的堆棧空間。
這個問題的棘手問題在於:
可以編譯尾調用,以便在堆棧或堆上不分配新幀。 目標代碼可以使用相同的堆棧指針值就地創建被調用者的堆棧幀,並無條件地將控制轉移到其目標代碼例程。
但我加粗了“可以”,因為這是語言實現者可以使用的選項 。 並非所有語言實現都在所有情況下優化所有尾調用。
知道F#的人將不得不對你案件的細節發表評論,但我可以在你的提交標題中回答這個問題:
continuation + tail遞歸技巧是否真的為堆空間交換堆棧空間?
答案是它完全取決於您的語言實現。 特別是,試圖在更常規的VM(如Java VM)上提供尾部調用優化的實現通常不會提供不完整的TCO,邊緣情況不起作用。
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.