在制定answer to another SO question时,我遇到了一些关于Mathematica中尾部递归的奇怪行为。
可能会执行Mathematica documentation的tail call optimization提示。但我自己的实验给出了相互矛盾的结果。对比,例如,以下两个表达式。第一个崩溃7.0.1内核,可能是由于堆栈耗尽:
(* warning: crashes the kernel! *)
Module[{f, n = 0},
f[x_] := (n += 1; f[x + 1]);
TimeConstrained[Block[{$RecursionLimit = Infinity}, f[0]], 300, n]
]
第二个运行完成,似乎利用尾调用优化来返回有意义的结果:
Module[{f, n = 0},
f[x_] := Null /; (n += 1; False);
f[x_] := f[x + 1];
TimeConstrained[Block[{$IterationLimit = Infinity}, f[0]], 300, n]
]
两个表达式都定义了尾递归函数f
。在第一个函数的情况下,Mathematica显然认为复合语句的存在足以击败尾调用优化的任何机会。另请注意,第一个表达式由$RecursionLimit
控制,第二个表达式由$IterationLimit
控制 - 这是Mathematica以不同方式处理这两个表达式的标志。 (注意:上面提到的SO答案有一个较少设法的功能,成功利用尾调用优化)。
所以,问题是:有没有人知道Mathematica执行递归函数尾调优化的情况?在Mathematica文档或其他WRI材料中引用最终声明将是理想的。也欢迎投机。
答案 0 :(得分:22)
我可以总结一下我个人经历所带来的结论,并且免责声明以下内容可能并非完全正确的解释。 anwer似乎存在于Mathematica调用堆栈和传统调用堆栈之间的差异,传统调用堆栈源于Mathematica模式定义的函数,实际上是规则。所以,没有真正的函数调用。 Mathematica需要一个堆栈的原因不同:因为正常的评估是从表达式树的底部发生的,所以它必须保留中间表达式,以防(sub)表达式的更深和更深的部分因规则应用而被替换(某些部分)表达从底部增长)。特别是对于定义我们在其他语言中称为非尾递归函数的规则的情况就是这种情况。所以,再一次,Mathematica中的堆栈是一堆中间表达式,而不是函数调用。
这意味着,如果作为规则应用的结果,可以完整地重写(子)表达式,则表达式分支不需要保留在表达式堆栈上。这可能是Mathematica中所谓的尾调用优化 - 这就是为什么在这种情况下我们有迭代而不是递归(这是规则应用程序和函数调用之间差异的一个非常好的例子)。像f[x_]:=f[x+1]
这样的规则属于这种类型。但是,如果某个子表达式被重写,产生更多的表达式结构,则表达式必须存储在堆栈中。规则f[x_ /; x < 5] := (n += 1; f[x + 1])
属于此类型,在我们回想起()
代表CompoundExpression[]
之前,该规则有点隐藏。示意图这里发生的是f[1] -> CompoundExpression[n+=1, f[2]] -> CompoundExpression[n+=1,CompoundExpression[n+=1,f[3]]]->etc
。即使每次调用f都是最后一次,它也会在完整CompoundExpression[]
执行之前发生,所以这仍然必须保留在表达式堆栈上。有人可能会争辩说,这是一个可以进行优化的地方,为CompoundExpression做一个例外,但这可能不容易实现。
现在,为了说明我上面示意性描述的堆栈累积过程,让我们限制递归调用的数量:
Clear[n, f, ff, fff];
n = 0;
f[x_ /; x < 5] := (n += 1; f[x + 1]);
ff[x_] := Null /; (n += 1; False);
ff[x_ /; x < 5] := ff[x + 1];
fff[x_ /; x < 5] := ce[n += 1, fff[x + 1]];
追踪评估:
In[57]:= Trace[f[1],f]
Out[57]= {f[1],n+=1;f[1+1],{f[2],n+=1;f[2+1],{f[3],n+=1;f[3+1],{f[4],n+=1;f[4+1]}}}}
In[58]:= Trace[ff[1],ff]
Out[58]= {ff[1],ff[1+1],ff[2],ff[2+1],ff[3],ff[3+1],ff[4],ff[4+1],ff[5]}
In[59]:= Trace[fff[1],fff]
Out[59]= {fff[1],ce[n+=1,fff[1+1]],{fff[2],ce[n+=1,fff[2+1]],{fff[3],ce[n+=1,fff[3+1]],
{fff[4],ce[n+=1,fff[4+1]]}}}}
你可以从中看到的是表达式堆栈为f
和fff
累积(后者用于表明这是一种通用机制,ce[]
只是一些任意的但是,对于ff
,为了模式匹配的目的,ff
的第一个定义是尝试但不匹配的规则,第二个定义在其中重写ff[arg_]
整体,并不会产生需要进一步重写的更深层子部分。所以,底线似乎是你应该分析你的函数,看看它的递归调用是否会从底部增长评估表达式。如果是,就Mathematica而言,它不是尾递归的。
如果没有显示如何手动执行尾调用优化,我的答案就不会完整。作为示例,让我们考虑Select的递归实现。我们将使用Mathematica链表来使其合理有效而不是玩具。下面是非尾递归实现的代码:
Clear[toLinkedList, test, selrecBad, sel, selrec, selTR]
toLinkedList[x_List] := Fold[{#2, #1} &, {}, Reverse[x]];
selrecBad[fst_?test, rest_List] := {fst,If[rest === {}, {}, selrecBad @@ rest]};
selrecBad[fst_, rest_List] := If[rest === {}, {}, selrecBad @@ rest];
sel[x_List, testF_] := Block[{test = testF}, Flatten[selrecBad @@ toLinkedList[x]]]
我使用Block和selrecBad的原因是为了更容易使用Trace。现在,这会炸掉我机器上的堆栈:
Block[{$RecursionLimit = Infinity}, sel[Range[300000], EvenQ]] // Short // Timing
您可以在小型列表上进行跟踪,了解原因:
In[7]:= Trace[sel[Range[5],OddQ],selrecBad]
Out[7]= {{{selrecBad[1,{2,{3,{4,{5,{}}}}}],{1,If[{2,{3,{4,{5,{}}}}}==={},{},selrecBad@@{2,{3,{4,
{5,{}}}}}]},{selrecBad[2,{3,{4,{5,{}}}}],If[{3,{4,{5,{}}}}==={},{},selrecBad@@{3,{4,{5,
{}}}}],selrecBad[3,{4,{5,{}}}],{3,If[{4,{5,{}}}==={},{},selrecBad@@{4,{5,{}}}]},{selrecBad[4,
{5,{}}],If[{5,{}}==={},{},selrecBad@@{5,{}}],selrecBad[5,{}],{5,If[{}==={},{},selrecBad@@{}]}}}}}}
结果是在列表中越来越深入地累积结果。解决方案是不增加结果表达式的深度,实现这一目的的一种方法是使selrecBad接受一个额外的参数,这是累积结果的(链接)列表:
selrec[{fst_?test, rest_List}, accum_List] :=
If[rest === {}, {accum, fst}, selrec[rest, {accum, fst}]];
selrec[{fst_, rest_List}, accum_List] :=
If[rest === {}, accum, selrec[rest, accum]]
并相应地修改主要功能:
selTR[x_List, testF_] := Block[{test = testF}, Flatten[selrec[toLinkedList[x], {}]]]
这将通过我们的功率测试:
In[14]:= Block[{$IterationLimit= Infinity},selTR[Range[300000],EvenQ]]//Short//Timing
Out[14]= {0.813,{2,4,6,8,10,12,14,16,18,20,
<<149981>>,299984,299986,299988,299990,299992,299994,299996,299998,300000}}
(请注意,这里我们必须修改$ IterationLimit,这是个好兆头)。并使用Trace揭示了原因:
In[15]:= Trace[selTR[Range[5],OddQ],selrec]
Out[15]= {{{selrec[{1,{2,{3,{4,{5,{}}}}}},{}],If[{2,{3,{4,{5,{}}}}}==={},{{},1},selrec[{2,{3,{4,
{5,{}}}}},{{},1}]],selrec[{2,{3,{4,{5,{}}}}},{{},1}],If[{3,{4,{5,{}}}}==={},{{},1},selrec[{3,
{4,{5,{}}}},{{},1}]],selrec[{3,{4,{5,{}}}},{{},1}],If[{4,{5,{}}}==={},{{{},1},3},selrec[{4,
{5,{}}},{{{},1},3}]],selrec[{4,{5,{}}},{{{},1},3}],If[{5,{}}==={},{{{},1},3},selrec[{5,
{}},{{{},1},3}]],selrec[{5,{}},{{{},1},3}],If[{}==={},{{{{},1},3},5},selrec[{},{{{{},1},3},5}]]}}}
也就是说,此版本不会累积中间表达式的深度,因为结果保存在单独的列表中。
答案 1 :(得分:4)
这个答案的想法是用一个不会使我们的表达式增长的包装器替换方括号()。请注意,我们正在寻找替代方法的函数实际上是CompoundExpression,因为OP正确地说明这个函数破坏了尾递归(参见Leonid的答案)。提供了两种解决方案。这定义了第一个包装器
SetAttributes[wrapper, HoldRest];
wrapper[first_, fin_] := fin
wrapper[first_, rest__] := wrapper[rest]
然后我们有了
Clear[f]
k = 0;
mmm = 1000;
f[n_ /; n < mmm] := wrapper[k += n, f[n + 1]];
f[mmm] := k + mmm
Block[{$IterationLimit = Infinity}, f[0]]
正确计算总计[范围[1000]]。
------ -----注
请注意,设置
会产生误导wrapper[fin_] := fin;
与案件一样
f[x_]:= wrapper[f[x+1]]
不会发生尾递归(因为具有HoldRest的包装器将在应用与包装器[fin _]相关联的规则之前评估单数参数)。
然后,上面关于f的定义没有用,因为人们可以简单地写
f[x_]:= f[x+1]
并进行所需的尾递归。
------另一个注意事项-----
如果我们为包装器提供了很多参数,它可能比必要的慢。用户可以选择写
f[x_]:=wrapper[g1;g2;g3;g4;g5;g6;g7 , f[x+1]]
第二个包装
第二个包装器将其参数提供给CompoundExpression,因此如果提供了许多参数,它将比第一个包装器更快。这定义了第二个包装器。
SetAttributes[holdLastWrapper, HoldAll]
holdLastWrapper[fin_] := fin
holdLastWrapper[other_, fin_] :=
Function[Null, #2, HoldRest][other, fin]
holdLastWrapper[others__, fin_] :=
holdLastWrapper[
Evaluate[CompoundExpression[others, Unevaluated[Sequence[]]]], fin]
注意:返回(空)序列通常在递归时非常有用。另见我的回答
https://mathematica.stackexchange.com/questions/18949/how-can-i-return-a-sequence
请注意,如果只提供一个参数,此函数仍然有效,因为它具有属性HoldAll而不是HoldRest,因此设置
f[x]:= holdLastWrapper[f[x+1]]
将产生尾递归(包装器没有此行为)。
速度比较
让我们创建一个很好的长列表(实际上是Head Hold的表达式)
nnnn = 1000;
incrHeld =
Prepend[DeleteCases[Hold @@ ConstantArray[Hold[c++], nnnn],
Hold, {2, Infinity}, Heads -> True], Unevaluated[c = 0]];
对于这些说明,我们可以比较包装器与CompoundExpression
的性能(和结果)holdLastWrapper @@ incrHeld // Timing
CompoundExpression @@ incrHeld // Timing
wrapper @@ incrHeld // Timing
- &GT; {{0.000856,999},{0.000783,999},{0.023752,999}}
<强>结论强>
如果您不确定何时会发生尾递归,或者您将向包装器提供多少参数,则第二个包装器会更好。如果您打算提供包装器2参数,例如在您意识到所有第二个包装器都是FeedExpression并且您决定自己执行此操作的情况下,第一个包装器会更好。
-----最后的注意事项----
在CompoundExpression [args,Unevaluated [expr]]中,在剥离CompoundExpression之前仍然会计算expr,因此这种类型的解决方案没有用。