缺点的什么性质可以消除尾部递归模缺点?


14

我熟悉基本尾部递归消除的概念,其中可以将返回调用自身直接结果的函数重写为迭代循环。

foo(...):
    # ...
    return foo(...)

我还了解,在特殊情况下,如果将递归调用包装在对的调用中,则仍然可以重写该函数cons

foo(...):
    # ...
    return (..., foo(...))

的什么性质cons允许?除了cons可以包装递归尾部调用而又不破坏我们迭代重写的功能之外,还有哪些功能呢?

GCC(但不是Clang)能够优化 “尾递归模 ”的示例但是尚不清楚哪种机制可以发现它或如何进行转换。

pow(x, n):
    if n == 0: return 1
    else if n == 1: return x
    else: return x * pow(x, n-1)

1
在您的Godbolt编译器资源管理器链接中,您的函数具有功能if(n==0) return 0;(不像您的问题那样返回1)。 x^0 = 1,所以这是一个错误。不过,这对于其余的问题并不重要;迭代式asm首先检查该特殊情况。但是奇怪的是,迭代实现引入了1 * x源代码中不存在的乘数,即使我们创建了一个float版本。 gcc.godbolt.org/z/eqwine(而gcc仅以。成功-ffast-math
Peter Cordes

@PeterCordes好收获。在return 0已定。乘以1很有趣。我不确定该怎么做。
Maxpm

我认为这是GCC在将其转换为循环时进行转换的一种副作用。显然,gcc在此处缺少一些优化,例如,即使每次都乘以相同的值,也要在float没有时缺少它-ffast-math。(除了1.0f可能是症结所在?)
Peter Cordes

Answers:


12

尽管GCC可能会使用临时规则,但您可以通过以下方式导出它们。我会用它pow来说明,因为您的foo定义是如此模糊。同样,foo最好将Oz理解为关于Oz语言的单分配变量的最后调用优化的一个实例,如计算机编程的概念,技术和模型中所述。使用单分配变量的好处是它允许保留在声明性编程范例中。本质上,您可以让结构foo返回的每个字段由单赋值变量表示,然后将其foo作为附加参数传递给。foo然后变成尾递归void返回函数。不需要特别的聪明。

回到pow第一,转变为延续过去的风格pow变成:

pow(x, n):
    return pow2(x, n, x => x)

pow2(x, n, k):
    if n == 0: return k(1)
    else if n == 1: return k(x)
    else: return pow2(x, n-1, y => k(x*y))

现在所有呼叫都是尾叫。但是,控制堆栈已移到表示连续体的封闭中的捕获环境中。

接下来,取消延续的功能。由于只有一个递归调用,因此表示脱机化连续的结果数据结构是一个列表。我们得到:

pow(x, n):
    return pow2(x, n, Nil)

pow2(x, n, k):
    if n == 0: return applyPow(k, 1)
    else if n == 1: return applyPow(k, x)
    else: return pow2(x, n-1, Cons(x, k))

applyPow(k, acc):
    match k with:
        case Nil: return acc
        case Cons(x, k):
            return applyPow(k, x*acc)

要做的applyPow(k, acc)是拿一张列表,即免费的monoid,k=Cons(x, Cons(x, Cons(x, Nil)))然后放入x*(x*(x*acc))。但是由于*是关联的并且通常与单元形成一个monoid 1,因此我们可以将其重新关联((x*x)*x)*acc为简单起见,1开始生产(((1*x)*x)*x)*acc。关键是,即使在获得结果之前,我们实际上也可以部分计算结果acc。这意味着k与其传递一个列表,它本质上是一些不完整的“语法”,我们将在最后“解释”它,而我们可以在进行时“解释”它。结果是Nil1在这种情况下,我们可以用monoid的单位替换,并且可以用monoid Cons的操作替换*,现在它k表示“运行产品”。applyPow(k, acc)然后变成k*acc我们可以内联pow2并简化生产的方式:

pow(x, n):
    return pow2(x, n, 1)

pow2(x, n, k):
    if n == 0: return k
    else if n == 1: return k*x
    else: return pow2(x, n-1, k*x)

原始的尾递归,累加器传递样式版本pow

当然,我并不是说GCC在编译时会做所有这些推理。我不知道GCC使用什么逻辑。我的观点只是做过一次这样的推理,识别模式并立即将原始源代码转换成最终形式相对容易。但是,CPS变换和去功能化变换完全是通用且机械的。从那里开始,可以使用融合,毁林或超级编译技术来尝试消除具体化的延续。如果不可能消除所有化后的延续的分配,投机性的转换可能会被抛弃。我怀疑,尽管如此,以全面的观点来看,这样做一直很昂贵,因此需要更多的临时方法。

如果您想变得荒唐可笑,可以查看论文《Recontining Continuationations》,该论文也使用CPS和连续性表示作为数据,但是与“尾递归模态”类似但有所不同。这描述了如何通过转换产生指针反向算法。

CPS转换和去功能化的这种模式对于理解而言是一个非常强大的工具,并且在我在此处列出的一系列论文中被很好地使用。


我相信,GCC用来代替您在此处显示的Continuation-Passing Style的技术是静态单一分配表格。
戴维斯洛

@Davislor尽管与CPS相关,但SSA既不影响过程的控制流,也不影响堆栈(或以其他方式引入需要动态分配的数据结构)。与SSA有关,CPS“做得太多”,这就是为什么行政范式(ANF)更适合SSA的原因。因此,GCC使用SSA,但SSA不会导致将控制堆栈视为可操作的数据结构。
德里克·埃尔金斯

对。我当时的回应是:“我并不是说GCC在编译时会做所有这些推理。我不知道GCC使用什么逻辑。” 同样,我的回答表明该转换在理论上是合理的,而不是说它是任何给定编译器使用的实现方法。(尽管您知道,许多编译器在优化过程中确实将程序转换为CPS。)
Davislor

8

我要花一会儿时间,但有一点。

半群

答案是二元归约运算的关联性质

那是非常抽象的,但是乘法就是一个很好的例子。如果xyz是一些自然数(或整数,有理数,实数或复数或N × N矩阵,或更多其他事物),则x × y是同一种xy的个数。我们从两个数字开始,所以它是一个二进制运算,然后得到一个,因此我们将拥有的数字数量减少了一个,从而使它成为减少运算。并且(x × y)× z始终与x ×(y ×z),这是关联属性。

(如果您已经知道所有这些,则可以跳到下一部分。)

您在计算机科学中经常看到的其他一些事情也以相同的方式起作用:

  • 将任何这些数字相加而不是相乘
  • 连接字符串("a"+"b"+"c""abc"你是否开始"ab"+"c""a"+"bc"
  • 将两个列表拼接在一起。 [a]++[b]++[c]类似地[a,b,c]从后到前或从前到后。
  • cons如果将头视为单例列表,则在头和尾上。那只是串联两个列表。
  • 取集合的并集或交集
  • 布尔和,布尔或
  • 按位&|^
  • 的功能的组合物:(˚F)∘ ħ X = ˚F ∘(ħX = ˚FħX)))
  • 最大值和最小值
  • 此外模p

有些事情没有:

  • 减法,因为1-(1-2)≠(1-1)-2
  • Xÿ = TAN(X + Ý),因为黄褐色(π/ 4 +π/ 4)是未定义
  • 负数相乘,因为-1×-1不是负数
  • 整数除法,它具有所有三个问题!
  • 逻辑不,因为它只有一个操作数,而不是两个
  • int print2(int x, int y) { return printf( "%d %d\n", x, y ); },as print2( print2(x,y), z );print2( x, print2(y,z) );具有不同的输出。

我们将其命名为一个有用的概念。具有这些属性的操作的集合为准群。因此,乘法下的实数是一个半群。您的问题原来是这种抽象在现实世界中变得有用的方式之一。 半组操作都可以按照您的要求进行优化。

在家尝试一下

据我所知,这种技术最早是在1974年在Daniel Friedman和David Wise的论文“将程式化的递归折叠成迭代”中描述的,尽管他们假定的属性比实际需要的要多。

Haskell是一种很好的语言来说明这一点,因为它的Semigroup标准库中有typeclass。它把泛型Semigroup的操作称为运算符<>。由于列表和字符串是的实例Semigroup,因此它们的实例定义为例如<>串联运算符++。正确导入后,[a] <> [b]是的别名[a] ++ [b],即[a,b]

但是,数字呢?我们刚才看到的数字类型是下半群或者另外乘法!那么哪一个<>适合Double?好吧,任一个!Haskell中定义了类型Product Doublewhere (<>) = (*)(即在Haskell实际定义),并且还Sum Doublewhere (<>) = (+)

一种皱纹是您使用了1是乘法身份的事实。具有身份的半群称为monoid,并在Haskell程序包中定义,该程序包Data.Monoid调用类型类的泛型身份元素memptySumProduct以及列表中的每个都有一个标识元件(0,1和[]分别),因此它们的实例Monoid以及Semigroup。(不要与monad混淆,所以请忘记我什至把它们提了出来。)

这些信息足以将您的算法转化为使用半定式的Haskell函数:

module StylizedRec (pow) where

import Data.Monoid as DM

pow :: Monoid a => a -> Word -> a
{- Applies the monoidal operation of the type of x, whatever that is, by
 - itself n times.  This is already in Haskell as Data.Monoid.mtimes, but
 - let’s write it out as an example.
 -}
pow _ 0 = mempty -- Special case: Return the nullary product.
pow x 1 = x      -- The base case.
pow x n = x <> (pow x (n-1)) -- The recursive case.

重要的是,请注意,这是尾递归模半群:每种情况都是一个值,一个尾递归调用或两者的半群乘积。另外,此示例碰巧mempty用于其中一种情况,但是如果我们不需要这样做,则可以使用更通用的typeclass来完成Semigroup

让我们在GHCI中加载该程序,并查看其工作方式:

*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49

还记得我们宣布pow了一个通用的Monoid,其类型我们叫a?我们给GHCI足够的信息来推断该类型a在这里Product Integer,这是一个instanceMonoid,其<>操作整数乘法。因此,pow 2 4将其递归扩展2<>2<>2<>22*2*2*216。到目前为止,一切都很好。

但是,我们的函数仅使用通用的monoid操作。之前,我说过还有另一个实例,Monoid称为Sum,其<>操作为+。我们可以试试吗?

*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14

现在,相同的扩展给了我们2+2+2+2而不是2*2*2*2。乘法就是加法,乘幂就是乘法!

但是我还给出了Haskell monoid的另一个示例:列表,其操作是串联的。

*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]

文字[2]告诉编译器这是一个列表,<>on上是++[2]++[2]++[2]++[2]也是[2,2,2,2]

最后,一个算法(实际上是两个)

通过简单的更换x[x],将转换为通用的算法,使用递归模半群为一体,创建一个列表。哪个名单? 该算法适用的元素列表<> 因为我们仅使用列表具有的半组运算,所以结果列表将与原始计算同构。并且由于原始操作是关联的,因此我们同样可以很好地从后到前或从前到后评估元素。

如果您的算法曾经达到基本情况并终止,则列表将为非空。由于终端案例返回了某些内容,因此它将是列表的最后一个元素,因此它将至少包含一个元素。

如何按顺序对列表的每个元素应用二进制归约运算?是的,折叠。所以,你可以替换[x]x,得到的元素列表,以减少<>,然后要么对折或左折列表:

*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16

与该版本foldr1实际上在标准库中存在,如sconcatSemigroupmconcatMonoid。它在列表上没有偷懒的右折。也就是说,它扩展[Product 2,Product 2,Product 2,Product 2]2<>(2<>(2<>(2)))

在这种情况下,这样做效率不高,因为在生成所有术语之前,您无法对各个术语进行任何操作。(在这里我曾经讨论过什么时候使用右折以及何时使用严格的左折,但这太离谱了。)

的版本foldl1'是经过严格评估的左折。也就是说,具有严格累加器的尾递归函数。计算结果为(((2)<>2)<>2)<>2,立即计算出来,不需要时再计算。(至少,折叠本身没有延迟:要折叠的列表是由另一个可能包含惰性求值的函数在此处生成的。)因此,折叠先计算(4<>2)<>2,然后立即计算8<>2,然后再计算16。这就是为什么我们需要将操作进行关联的原因:我们只是更改了括号的分组!

严格的左折等同于GCC所做的事情。 上一个示例中最左边的数字是累加器,在这种情况下为运行产品。在每个步骤中,它将乘以列表中的下一个数字。表示它的另一种方法是:对要相乘的值进行迭代,将运行乘积保持在累加器中,并且在每次迭代时,将累加器乘以下一个值。也就是说,这是一个while变相的循环。

有时可以使它同样有效。编译器可能能够优化内存中的列表数据结构。从理论上讲,它在编译时具有足够的信息,可以在此处确定应该这样做:[x]是单例,因此[x]<>xs与相同cons x xs。函数的每次迭代都可以重新使用相同的堆栈框架并在适当位置更新参数。

在特定情况下,右折或严格左折可能更合适,因此请确定要使用哪一种。还有一些事情只有正确折叠才能完成(例如,在不等待所有输入的情况下生成交互式输出,并在无限列表上进行操作)。不过,在这里,我们将一系列操作简化为一个简单的值,因此我们需要严格的左折。

因此,如您所见,可以自动将尾半递归模的任意半组(其中一个例子是乘法下的任何常规数值类型)的模数自动优化为惰性右折或严格左折哈斯克尔。

进一步推广

二进制操作的两个参数不必为同一类型,只要初始值与结果为同一类型即可。(当然,您总是可以翻转参数以匹配您正在执行的折叠的顺序(向左或向右)。)因此,您可能会反复向文件中添加补丁以获取更新的文件,或者以初始值为1.0,除以整数可累计浮点结果。或在空白列表前添加元素以获取列表。

泛化的另一种类型是不将折叠应用于列表,而是应用于其他Foldable数据结构。通常,不可变的线性链表不是给定算法所需的数据结构。我上面没有提到的一个问题是,将元素添加到列表的前面比后面添加元素要有效得多,并且当操作不是可交换的时,在操作x的左边和右边都没有应用相同。因此,您将需要使用另一对结构(例如一对列表或二叉树)来表示一种算法,该算法既可以应用于x右侧也可以应用于<>左侧。

还要注意,associative属性允许您以其他有用的方式重新组合操作,例如分治法:

times :: Monoid a => a -> Word -> a
times _ 0 = mempty
times x 1 = x
times x n | even n    = y <> y
          | otherwise = x <> y <> y
  where y = times x (n `quot` 2)

或自动并行性,其中每个线程将子范围缩小为一个值,然后将其与其他线程组合。


1
我们可以做一个实验来测试关联性是GCC进行此优化的能力的关键:gcc.godbolt.org/z/eqwinepow(float x, unsigned n)版本仅使用进行优化(这意味着。严格的浮点当然不是关联的,因为不同的时间=不同的舍入)。引入了C抽象机中不存在的a (但始终会得到相同的结果)。然后n-1乘法与递归相同,因此这是一个错过的优化。-ffast-math-fassociative-math1.0f * xdo{res*=x;}while(--n!=1)
Peter Cordes
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.