函数式编程——大量强调递归,为什么?

我正在学习函数式编程(FP)(使用 Scala)。从我最初的学习中得出的一个结论是 FP 严重依赖递归。而且,在 纯洁 FP 中,做迭代工作的唯一方法似乎是编写递归函数。

由于大量使用递归,因此 FP 不得不担心的下一个问题是 StackoverflowExceptions,这通常是由于长时间的缠绕递归调用造成的。通过引入一些优化(从 Scala v2.8开始,在维护堆栈框架和 @tailrec注释方面与尾递归相关的优化)来解决这个问题

谁能告诉我为什么递归对功能编程范型如此重要?在函数式编程语言的规范中,如果我们迭代地做一些事情,是否有什么东西被“违反”了?如果是的话,我也很想知道。

PS: 注意,我是函数式编程的新手,所以如果他们解释/回答我的问题,请随时指给我现有的资源。同时我也明白 Scala 特别提供了对迭代工作的支持。

14495 次浏览

纯函数式编程意味着没有副作用的编程。这意味着,如果你写一个循环,你的循环体不能产生副作用。因此,如果您希望您的循环执行某些操作,那么它必须重用前一次迭代的结果,并为下一次迭代生成某些内容。因此,循环体是一个函数,它以前一次执行的结果作为参数,并使用自己的结果调用下一次迭代。与直接为循环编写递归函数相比,这并没有很大的优势。

一个不做琐碎事情的程序将不得不在某个点上迭代某些事情。对于函数式编程,这意味着程序必须使用递归函数。

我认为函数式编程有两个基本属性:

  1. 函数是第一类成员(只有相关的,因为要使其有用,需要第二个属性)

  2. 函数是纯函数,也就是说,使用相同参数调用的函数返回相同的值。

现在,如果你用命令式编程,你必须使用赋值。

考虑一个 for 循环。它有一个索引,在每次迭代中,索引都有一个不同的值。因此,您可以定义一个返回此索引的函数。如果调用该函数两次,可能会得到不同的结果。这样就打破了第二条原则。

如果你违反了第二条原则。传递函数(原则1)变得非常危险,因为现在函数的结果可能取决于调用函数的时间和频率。

递归没有什么特别的。它是编程和数学中广泛使用的工具,仅此而已。然而,函数式语言通常是极简主义的。它们确实引入了许多奇特的概念,比如模式匹配、类型系统、列表内涵等等,但它只不过是用于非常普通、非常强大、但又简单和原始的工具的语法糖。这些工具包括: 函数抽象和函数应用。这是有意识的选择,因为语言核心的简单性使得推理变得更加容易。它还使编写编译器更加容易。用这些工具来描述循环的唯一方法是使用递归,因此命令式程序员可能会认为函数式编程与递归有关。它不是,它只是需要模仿那些不能在 goto语句上丢弃这个语法糖的可怜的循环,因此它是他们坚持的第一件事情之一。

另一个需要递归的地方(可能是间接的)是处理递归定义的数据结构。最常见的例子是 list ADT。在 FP 中,它通常被定义为这样的 data List a = Nil | Branch a (List a)。因为这里的 ADT 定义是递归的,所以它的处理函数也应该是递归的。再次强调,递归在这里并不特殊: 在命令式语言和函数式语言中,以递归方式处理这样的 ADT 看起来很自然。那么,在列表式的 ADT 命令循环仍然可以采用,但在不同的树形结构的情况下,他们不能。

所以在递归中没有什么特别的。它只是另一种类型的函数应用程序。然而,由于现代计算系统的局限性(这种局限性来自于 C 语言中糟糕的设计决策,C 语言实际上是标准的跨平台汇编程序)函数调用即使是尾部调用也不能无限嵌套。正因为如此,函数式编程语言的设计者要么限制允许尾部调用的尾部递归(scala) ,要么使用复杂的技术,比如践踏(旧的 ghc codegen) ,要么直接编译成 asm (现代的 ghc codegen)。

TL; DR: FP 中的递归没有什么特别之处,至少在 IP 中没有什么特别之处,然而,由于 JVM 的限制,尾递归是 Scala 中唯一允许的尾调用类型。

带来递归操作 规定的特性是不可变变量。

考虑一个用于计算列表和的简单函数(伪代码) :

fun calculateSum(list):
sum = 0
for each element in list: # dubious
sum = sum + element # impossible!
return sum

现在,列表的每个迭代中的 element是不同的,但是我们可以重写这个函数,使用带有 lambda 参数的 foreach函数来解决这个问题:

fun calculateSum(list):
sum = 0
foreach(list, lambda element:
sum = sum + element # impossible!
)
return sum

尽管如此,在每次运行 lambda 时,sum变量的值必须改变。这在具有不可变变量的语言中是非法的,所以你必须以一种不会使状态发生变化的方式重写它:

fun calculateSum([H|T]):
return H + calculateSum(T)


fun calculateSum([]):
return 0

现在,这个实现将需要大量地向调用堆栈推送和从调用堆栈弹出,而且所有小操作都会这样做的程序不会很快运行。因此,我们将其重写为尾递归,这样编译器就可以进行尾调用优化:

fun calculateSum([H|T], partialSum):
return calculateSum(T, H + partialSum)


fun calculateSum([], partialSum):
return partialSum


fun calculateSum(list):
return calculateSum(list, 0)

当然,如果您希望无限期地循环,那么您绝对需要一个尾递归调用,否则它将堆栈溢出。

Scala 中的 @tailrec注释是一个帮助你分析哪些函数是尾递归的工具。你声明“这个函数是尾递归的”,然后编译器就会告诉你是否错了。与其他函数式语言相比,这一点在 Scala 中尤为重要,因为它运行的机器 JVM 并不支持尾部调用优化,所以不可能像在其他函数式语言中那样在 Scala 中获得尾部调用优化。

递归用于处理归纳定义的数据,归纳定义的数据无处不在。

当您在更高层次的抽象上操作时,递归是很自然的。函数式编程不仅仅是使用函数进行编码; 它还涉及到在更高层次的抽象上进行操作,在这个层次上您自然会使用函数。使用函数,从任何有意义的上下文重用相同的函数(再次调用它)是很自然的。

世界是由相似的/相同的构件重复构建而成的。如果你把一块布料切成两半,你就得到了两块布料。数学归纳法是数学的核心。我们人类计数(如 一,二,三。)。任何 归纳定义 < em > thing (如 {从1开始的数字为{1数字2})都可以通过递归函数自然地进行处理/分析,这取决于定义/构造该事物的相同情况。

递归无处不在。无论如何,任何迭代循环都是伪装的递归,因为当您重新进入该循环时,您将再次进入 一模一样循环(只是使用可能不同的循环变量)。所以它不像 发明关于计算的新概念,它更像 发现的基础,使它成为 露骨


所以,递归是很自然的。我们只是写下一些关于我们的问题的定律,一些涉及我们正在定义的函数的方程,这些方程保留了一些不变量(假设函数是连贯定义的) ,用简化的术语重新定义了问题,瞧!我们有解决办法。

例如,计算列表长度的函数(归纳定义的递归类型)。假设它已定义,并返回列表的长度,这并不奇怪。它必须遵守哪些法律?在什么样的问题简化下保持什么不变量?

最直接的方法是将列表分解为它的头元素,剩下的-也就是列表的尾部(根据列表的定义/构造方式)。法律规定,

length (x:xs) = 1 + length xs

那空名单呢? 肯定是那个

length [] = 0

那么我们如何编写这样一个函数呢?等等,我们已经写好了!(在 Haskell 中,如果您想知道函数应用程序在哪里用并置表示,括号仅用于分组,而 (x:xs)是一个列表,其中 x是它的第一个元素,而 xs是它们的其余部分)。

我们需要一种语言来允许这样的编程风格,就是它有 TCO(也许,有点奢侈,TRMCO) ,所以没有堆栈爆炸,我们就设置好了。


另一个问题是代码变量和/或数据结构(记录字段等)的纯粹性——不可变性。

除了让我们的大脑不必追踪什么在什么时候发生了变化之外,它还让时间在我们的代码中显而易见,而不是隐藏在我们“改变”的可变变量/数据中。我们只能在命令式代码中“改变”变量 从现在开始的值——我们不能很好地改变它过去的值,对吗?

因此,我们最终得到的是记录下来的变更历史的列表,变更在代码中显而易见: 我们编写的是 let x2 = x1 + 2,而不是 x := x + 2。关于代码 简单多了它让推理。


为了在使用 TCO的尾递归的上下文中解决不可变性问题,请考虑在累加器参数范例下对上述函数 length的尾递归重写:

length xs = length2 0 xs              -- the invariant:
length2 a []     = a                  --     1st arg plus
length2 a (x:xs) = length2 (a+1) xs   --     the length of 2nd arg

在这里,TCO 意味着除了直接跳转之外的调用帧重用,因此 length [1,2,3]的调用链可以被看作是实际上根据函数的参数改变了调用堆栈帧的条目:

length [1,2,3]
length2 0 [1,2,3]       -- a=0  (x:xs)=[1,2,3]
length2 1 [2,3]         -- a=1  (x:xs)=[2,3]
length2 2 [3]           -- a=2  (x:xs)=[3]
length2 3 []            -- a=3  (x:xs)=[]
3

在纯语言中,没有任何值变异原语,表示更改的唯一方法是将更新后的值作为参数传递给 一个函数,以便进一步处理。如果进一步的处理与前面的相同,那么自然我们必须为此调用相同的函数,将更新后的值作为参数传递给它。这就是递归。


下面介绍了计算参数列表 露骨长度的全部历史(如果需要,还可以重用) :

length xs = last results
where
results = length3 0 xs
length3 a []     = [a]
length3 a (x:xs) = a : length3 (a+1) xs

在哈斯克尔,这可以称为守护递归,或同递归(至少我是这么认为的)。

避免副作用是函数式编程的支柱之一(另一支柱是使用高阶函数)。

想象一下如何依靠突变来使用命令流控制 没有,这可能吗?

当然,for (var i = 0; i < 10; i++) ...依赖于突变(i++)。事实上,任何条件循环构造都会这样做。在 while (something) ...中,something将依赖于一些可变的状态。当然,while (true) ...不使用突变。但是如果你想走出这个循环,你需要一个 if (something) break。真的,试着想一个(非无限的)循环机制,而不是依赖于变异的递归。

for (var x in someCollection) ...呢?现在我们离函数式编程越来越近了。可以将 x看作是循环主体的 参数。重用名称与重新分配值是不一样的。也许在循环体中,yield returning 值作为 x(无突变)的表达式。

完全等效的是,你可以将 for循环的主体移动到函数 foo (x) ...的主体中,然后使用一个高阶函数将它映射到 someCollection上——用类似于 Map(foo, someCollection)的东西替换你的循环结构。

但是如何在不发生变异的情况下实现库函数 Map呢?当然是用递归了!已经为你准备好了。一旦开始使用高阶函数的第二个支柱来替换循环构造,那么自己实现递归函数的情况就变得不那么常见了。

此外,对于尾部呼叫优化,递归定义相当于一个迭代过程。你可能也喜欢这篇博文: http://blogs.msdn.com/b/ashleyf/archive/2010/02/06/recursion-is-the-new-iteration.aspx

上次使用函数式语言(Clojure)时,我甚至从未想过要使用递归。一切都可以作为一组事物来处理,一个函数被应用于得到部分产品,另一个函数被应用于其中,直到达到最终的结果。

递归只是一种方法,而且不一定是最清晰的方法,来处理任何 g 通常必须处理的多个项

为了新的 FP 学习者,我想加上我的两分钱。正如在一些答案中提到的,递归是他们使用不可变变量,但是为什么我们需要这样做呢?这是因为它使得在多个内核上并行运行程序变得更加容易,但是为什么我们要这样做呢?难道我们就不能像以前一样,在单一核心中运行它,并且一如既往地快乐吗?不是,因为进程的内容一天比一天增加,CPU 时钟周期不可能比增加更多的内核显著增加。在过去的十年里,消费电脑的时钟速度一直在2.7 GHz 到3.0 GHz 之间,芯片设计者在安装越来越多的晶体管方面遇到了问题。FP 也是他们很长时间以来的产物,但是由于当时使用递归和内存非常昂贵,所以没有得到发展,但是由于时钟速度一年比一年快,所以社区决定继续使用 OOP 编辑: 这是相当快的,我只有几分钟