函数式编程 - 非常强调递归,为啥?

Posted

技术标签:

【中文标题】函数式编程 - 非常强调递归,为啥?【英文标题】:Functional Programming - Lots of emphasis on recursion, why?函数式编程 - 非常强调递归,为什么? 【发布时间】:2012-09-21 11:51:11 【问题描述】:

我开始了解函数式编程 [FP](使用 Scala)。从我最初的学习中得出的一件事是 FP 严重依赖递归。而且看起来,在 pure FP 中,进行迭代的唯一方法是编写递归函数。

由于递归的大量使用,FPs 不得不担心的下一个问题是***Exceptions,通常是由于长时间的递归调用。这是通过引入一些优化来解决的(从 Scala v2.8 开始,与尾递归相关的堆栈帧维护优化和 @tailrec 注释)

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

PS:请注意,我是函数式编程的新手,所以如果他们解释/回答我的问题,请随时向我指出现有资源。此外,我确实理解 Scala 也特别提供对迭代的支持。

【问题讨论】:

我不知道这是否有帮助,但大多数问题都可以使用递归有效地解决。在大多数情况下,我们发现自己必须对同一操作的结果重复执行同一组操作。此类问题属于The divide and conquer paradigm 的范畴。但我从算法的角度给你一个看法。我不知道为什么递归优于迭代而不是两者都可以解决的问题。但在大多数分而治之的问题中,递归是唯一的乐趣。 @NithishInpursuitOfhappiness 当你依赖内置堆栈时,代码会更简单,当然,但在大多数情况下,用循环替换递归+手动操作非常简单(只是丑陋)分配的堆栈。 @NithishInpursuitOfhappiness 怎么样?我不是在谈论选择不分而治之的不同算法,我说的是使用语言实现堆栈上的 O(1) 空间来实现相同的算法。您唯一更改的是将每个递归调用替换为推送到堆栈/数组(这是 O(1)),并相应地使用数组访问(也是 O(1))对本地人的每次访问。有时它甚至更简单,并且您可以处理相同的时间复杂度和 O(1) 空间使用,而不是 O(n) 或 O(log n) 空间使用。 @NithishInpursuitOfhappiness 在算法的迭代和递归实现之间进行转换并没有什么特别之处。对于二分搜索,***有a recursive 和an iterative 两种版本。 @NithishInpursuitOfhappiness 不。我只是简单地谈论将隐式调用堆栈换成显式调用堆栈,而不改变算法的任何内容。我现在写一个例子。正如 Magnus Hoff 指出的那样,在某些情况下(例如二分查找或其他尾递归函数),还有一种更优雅的迭代措辞,甚至不需要堆栈。 【参考方案1】:

纯函数式编程意味着没有副作用的编程。这意味着,例如,如果您编写一个循环,则循环的主体不会产生副作用。因此,如果你想让你的循环做某事,它必须重用上一次迭代的结果并为下一次迭代产生一些东西。因此,您的循环体是一个函数,将先前执行的结果作为参数,并以自己的结果调用自身以进行下一次迭代。与直接为循环编写递归函数相比,这并没有太大的优势。

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

【讨论】:

愚蠢的问题 - 在函数范式中不产生“副作用”的程序有什么用处:-) 在纯函数式编程中,程序的结果是调用的“根”函数返回的任何内容。该理论通过程序在输入和输出之间创建的关系来定义程序的行为。图灵机有一条磁带作为输入,改变它,结果就是执行结束时的结果。在函数式编程中,输入是函数的参数,输出是它返回的内容。我们经常使用monads来处理函数式语言中各种类型的输入/输出。 @peakit 你是对的,有用性将接近于零。所以所有真正的语言都提供了一些引起副作用的方法。但是在像 haskell 这样的语言中,它们仅限于非常特殊的对象(IO-Monads) @peakit:没有副作用的纯函数式语言是可能的,最流行的纯函数式语言是 Haskell。在纯函数式语言中,产生“副作用”(例如输入/输出)的函数被认为是一个将“宇宙”作为参数并返回另一个“宇宙”的函数(例如 printLn 接受一个宇宙并返回一个新的宇宙控制台显示打印的字符串)。因此,该语言完全没有副作用,同时在实践中仍然有用。 在纯函数式语言中,monad 没有什么特别之处。它只是将表达式的结果传递给下一条语句的语法糖。一系列“语句”a; b; c; 可以被认为是c(b(a(U))),其中 U 是语句之前的全域,每个“语句”返回一个应用了该语句的“副作用”的新全域。【参考方案2】:

Church Turing thesis 强调了不同可计算性模型之间的等价性。

使用递归,我们在解决一些问题时不需要可变状态,这使得用更简单的术语指定语义成为可能。因此,在形式上,解决方案可以更简单。

我认为 Prolog 比函数式语言更好地展示了递归的有效性(它没有迭代),以及我们在使用它时遇到的实际限制。

【讨论】:

从技术上讲,Haskell 也没有迭代——只是围绕递归进行了非常聪明的抽象,使它看起来就像在你需要它时那样。 forM_ 就像一个普通的 foreach 循环,但它是根据 IO 动作列表的映射来实现的,而 IO 动作反过来可以模拟可变性,但通过链式函数调用来实现。【参考方案3】:

我认为有两个属性对函数式编程至关重要:

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

    函数是纯函数,即使用相同参数调用的函数返回相同的值。

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

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

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

【讨论】:

【参考方案4】:

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

另一个需要(可能是间接的)递归的地方是处理递归定义的数据结构。最常见的例子是listADT。在 FP 中,它通常这样定义 data List a = Nil | Branch a (List a) 。由于这里ADT的定义是递归的,它的处理函数也应该是递归的。同样,这里的递归并不特别:以递归方式处理这种 ADT 在命令式和函数式语言中看起来都很自然。好吧,对于类似列表的 ADT 命令式循环仍然可以采用,但对于不同的树状结构则不能。

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

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

【讨论】:

【参考方案5】:

不可变变量带来了要求你以递归方式做事的特性。

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

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

不过,sum 变量的值必须在 lambda 的每次运行中进行更改。这在具有不可变变量的语言中是非法的,因此您必须以不改变状态的方式重写它:

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 中进行尾调用优化其他函数式语言。

【讨论】:

不可变状态并不意味着我们不能重新绑定变量(在这种情况下为sum),只是变量指向的数据结构永远不能改变。但是你是对的,例如 Erlang 不允许重新绑定变量。 @davidhq 不可变变量和不可变数据结构是两个不同的东西。【参考方案6】:

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

当您在更高的抽象级别上进行操作时,递归是很自然的。函数式编程不仅仅是用函数编码;它是关于在更高级别的抽象上操作,您自然会使用函数。使用函数,在任何有意义的上下文中重用相同的函数(再次调用它)是很自然的。

世界是通过重复相似/相同的构建块构建的。如果你把一块布料切成两半,你就有两块布料。数学归纳法是数学的核心。我们,人类,计数(如,1,2,3...)。任何inductively defined thing(例如,numbers from 1 是 1,并且 numbers from 2)很自然地可以通过递归函数处理/分析,根据相同定义/构造该事物的案例。

递归无处不在。无论如何,任何迭代循环都是变相的递归,因为当您重新进入该循环时,您会再次进入 相同的 循环(可能只是使用不同的循环变量)。所以这不像发明关于计算的新概念,它更像是发现基础,并使其明确


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

一个例子,一个计算列表长度的函数(一种归纳定义的递归数据类型)。假设它已定义,并返回列表的长度,这并不奇怪。它必须遵守哪些法律?在问题的何种简化下保留了哪些不变量?

最直接的方法是将列表分成其头部元素,其余部分 - 也就是列表的尾部(根据列表的定义/构造方式)。法律是,

length (x:xs) = 1 + length xs

呃!但是空列表呢?应该是这样的

length [] = 0

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

对于一种允许这种编程风格的语言,我们所需要的只是它具有TCO(也许有点奢侈,TRMCO),所以没有堆栈爆炸,我们已经准备好了。


另一件事是纯度 - 代码变量和/或数据结构(记录的字段等)的不变性。

除了让我们的大脑不必跟踪何时发生变化之外,它的作用是让时间在我们的代码中明确显示,而不是隐藏在我们“变化”的可变变量/数据中。我们只能在命令式代码中“改变”变量的值从现在开始 - 我们不能很好地改变过去的值,不是吗?

所以我们最终得到了记录的更改历史列表,更改在代码中明确显示:我们写 x := x + 2 而不是 let x2 = x1 + 2。 It makes 推理代码 so much easier.


要使用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

在 Haskell 中,这被称为保护递归或核心递归(至少我认为是这样)。

【讨论】:

【参考方案7】:

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

想象一下您如何使用命令式流控制而不依赖于突变。有可能吗?

当然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

【讨论】:

在没有显式循环的情况下使用命令式流控制绝对是可能的,并且各种编程语言都这样做:APL、J、Sisal、Vector Pascal 等等。 (如果这不算数,因为循环仍在幕后进行,那么所有函数式语言都因相同的逻辑而被取消资格。)另一种方法是数据流系统,其中每个处理器都有自己的无状态、无限循环,而你将它们链接在一起形成一条装配线......然后你让生产线本身绕一圈。 :) 重点是表达迭代过程不使用突变需要递归地表达它们。没有显式循环的表达很容易(例如提到的Map 示例)。 是的,但是递归本身依赖于可变状态:发生变异的是解释器中的调用堆栈。它只是对开发人员隐藏。数组编程语言也隐藏了突变,但它不是底层的递归。在 J 中,你可以说'i。 5' 并得到 '0 1 2 3 4' 或 '1 + i。 5',你得到'1 2 3 4 5'。所涉及的任何可变状态都处于解释器级别,就像 lambda 演算风格的语言一样......但在引擎盖下,它可能正在执行循环,也可能正在并行执行所有加法步骤(使用 intel 的 mmx / sse 指令,或你的显卡) 啊,是的。当我说Map 实现“......当然是递归!”时,我想我明白了你的观点。不一定是真的。不知道 J,我将您的示例与 F# 联系起来:您可以说 [0..4] 并得到 [0; 1; 2; 3; 4] 或说 List.map ((+) 1) [0..4] 并得到 [1; 2; 3; 4; 5]。在引擎盖下List.map 可能会使用它认为合适的突变。事实上,它在某个时候都是加载/存储。好的是将这种方法留给编译器和库作者。几年前我们离开了goto(尽管在某些时候它都是分支)。现在让我们远离loadstore【参考方案8】:

上次我使用函数式语言 (Clojure) 时,我什至没有尝试使用递归。一切都可以作为一组事物来处理,对其应用一个函数得到零件产品,再对其应用另一个函数,直到得到最终结果。

递归只是一种方式,不一定是最清晰的方式来处理您通常必须处理的多个项目以处理任何 g

【讨论】:

【参考方案9】:

为了新的 FP 学习者,我想加我的 2 美分。正如一些答案中提到的,递归是他们利用不可变变量但为什么我们需要这样做?这是因为它可以轻松地在多个内核上并行运行程序,但我们为什么要这样做呢?我们不能在单核中运行它并像往常一样快乐吗?不,因为要处理的内容每天都在增加,而且 CPU 时钟周期不能比添加更多内核显着增加。从过去的十年开始,消费类计算机的时钟速度一直在 2.7 ghz 到 3.0 ghz 左右,而芯片设计人员在安装越来越多的晶体管时遇到了问题。FP 也很长一段时间以来一直是他们的产品,但没有接受因为它使用递归和内存在那些日子里非常昂贵,但随着时钟速度年复一年地飙升,所以社区决定继续使用 OOP 编辑:速度非常快,我只有几分钟

【讨论】:

换句话说:在“过去”中,由于硬件限制,函数式编程停止了。如今,函数式编程是解决……硬件限制的方法。一定会喜欢其中的讽刺意味。

以上是关于函数式编程 - 非常强调递归,为啥?的主要内容,如果未能解决你的问题,请参考以下文章

函数式编程

函数式编程~懂?

函数式编程-尾递归尾调用

转:iOS与函数式编程

Java 函数式编程

Python进阶内容--- 函数式编程