为什么F#编译器不为这个函数创建尾部调用?

我在F#中的固定点组合器遇到问题:

let rec fix f a = f (fix f) a

fix (fun body num -> 
    if num = 1000000 
    then System.Console.WriteLine "Done!" 
    else body (num + 1)
) 0

(这段代码只是为了说明问题,它是专门编写的,以便生成的IL代码易于阅读。)

这段代码 - 当优化和tailcalls启用编译 - 导致StackOverflowException 。 我查看了IL代码,并可以将问题追踪到要fix的调用中的lambda:

.method assembly static void f@1 (class FSharpFunc`2<int32, class Unit> body,int32 num)
{
ldarg.1
ldc.i4 1000000
bne.un.s IL_0014

ldstr "Done!"
call void Console::WriteLine(string)
ret

IL_0014: ldarg.0 // Load the 'body' function onto the stack.
ldarg.1 // Load num onto the stack.
ldc.i4.1
add

// Invoke the 'body' function with num+1 as argument.
callvirt instance !1 class FSharpFunc`2<int32, class Unit>::Invoke(!0)
// Throw away the unit result.
pop
ret
}

(我修改了一些代码,以便阅读。)

StackOverflowException的原因是对body的调用不是尾调用(底部的callvirt指令)。 原因是编译器创建了一个实际返回Unit的lambda的调用!

因此,在C#术语中:当它真的应该是Action<Int32>Func<Int32,Unit> Body是Func<Int32,Unit> Action<Int32> 。 由于该调用返回了必须丢弃的内容,因此不能作为尾调用。 还要注意,方法f@1被编译为void ,而不是Unit ,这就是调用参数的结果必须被丢弃的原因。

这实际上是打算还是我可以做些什么呢? 编译器处理这个lambda的方式使得定点组合器在我打算使用它的所有目的中都没有用处。


我只想补充说,只要你返回一些结果,它就可以正常工作。 只有不返回的函数不能按预期工作。

这工作:

let rec fix f a = f (fix f) a

fix (fun body num ->
    if num = 1000000
    then System.Console.WriteLine "Done!"; 0
    else body (num + 1)
) 0
|> ignore

现在这是为lambda生成的代码:

.method assembly static int32 f@11 (class FSharpFunc`2<int32, int32> body, int32 num)
{
ldarg.1
ldc.i4 1000000
bne.un.s IL_0015

ldstr "Done!"
call void Console::WriteLine(string)
ldc.i4.0
ret

IL_0015: ldarg.0
ldarg.1
ldc.i4.1
add
tail.
callvirt instance !1 class FSharpFunc`2<int32, int32>::Invoke(!0)
ret
}

现在有一个尾声。 一切正常。


fix的IL代码(在评论中进行讨论):

.method public static !!b fix<a, b> (class FSharpFunc`2<class FSharpFunc`2<!!a, !!b>, class FSharpFunc`2<!!a, !!b>> f, !!a a) 
{    
    ldarg.0
    ldarg.0
    newobj instance void class Program/fix@11<!!a, !!b>::.ctor(class FSharpFunc`2<class FSharpFunc`2<!0, !1>, class FSharpFunc`2<!0, !1>>)
    ldarg.1
    tail.
    call !!0 class FSharpFunc`2<class FSharpFunc`2<!!a, !!b>, !!a>::InvokeFast<!!b>(class FSharpFunc`2<!0, class FSharpFunc`2<!1, !!0>>, !0, !1)
    ret
}

所以在我看来, (fix f)定义内的(fix f)不是发生在这个时候的递归调用,而仅仅是一个fix自身的参考 - 与参数f一起被存储到一个名为closure的闭包中Program/fix@11并作为参数传递给lambda,然后通过这个闭包实际调用fix

否则这将是从一开始的无限递归, fix将是无用的。

我使用F#版本3.1.2,F#交互版本12.0.30815.0


请:

我对替代解决方案不感兴趣。 我只想知道为什么编译器返回一个Unit ,当lambda不产生结果时需要抛出它。


事实上,你已经回答了你自己的问题。 引用源代码的评论,

// Throw away the unit result

是调用之后的挂起操作,从而阻止编译器在此处使用尾部调用。

Keith Battocchi发表了一篇很棒的博客文章,名为“F#中的尾巴呼叫”(滚动到“限制/呼叫功能值返回单元”部分),它发现了很多细节。

用两个词:
通常,F#函数… -> unit被编译为返回void .NET方法。
但是,作为值处理的函数(例如,那些作为参数传递给高阶函数的函数)存储在类型为('a->'b)对象中,因此它们实际返回Microsoft.FSharp.Core.Unit ,而不是void
编译器需要在返回之前从堆栈中弹出虚拟unit
因此,在递归调用之后有一个挂起的操作,因此编译器无法将其优化为尾部调用。

好消息:

请注意,只有在将第一类函数用作值时才会出现此问题。 调用返回void的普通.NET方法不会出现这个问题,因为没有返回值从堆栈弹出。


要调用优化你的代码,编译器必须调用优化fix 。 在修正中使用高阶函数时,编译器会感到困惑。

如果你想要一个尾递归fix ,试着以不同的方式定义它:

let rec iter p f x =
  if p x then x
  else iter p f (f x)

iter ((=) 100000000) ((+) 1) 0

有趣的事实:由于Haskell评估表达式,您的fix不会在Haskell中堆栈溢出:Haskell使用图缩减,这与使用调用堆栈不同。

let fix f = f (fix f)
fix (f x -> if x == 100000000 then -1 else f (x + 1)) 0

说到你的第二个例子,.NET即时发布可能能够在运行时优化尾部调用。 由于它是一种优化,它取决于运行时的智能程度:有没有返回值可能会导致JIT优化器停滞。 例如,我的机器上的Mono没有优化你的第二个例子。

另请参阅:生成尾部调用操作码

链接地址: http://www.djcxy.com/p/80615.html

上一篇: Why does the F# compiler not create a tail call for this function?

下一篇: Recursive Function Calls Throw StackOverFlowException