TL;DR:递归用于处理无处不在的归纳定义数据。
当您在更高级别的抽象上操作时,递归是自然的。函数式编程不仅仅是用函数编码;它是关于在更高级别的抽象上操作,您自然会使用函数。使用函数,从任何有意义的上下文中重用相同的函数(再次调用它)是很自然的。
世界是通过重复相似/相同的构建块来构建的。如果你把一块布料切成两半,你就有两块布料。数学归纳法是数学的核心。我们,人类,计数(如,1,2,3...)。根据定义/构造该事物的相同情况,任何归纳定义的事物(例如{1} 中的数字是 {1和2}中的数字)都可以自然地由递归函数处理/分析。
递归无处不在。无论如何,任何迭代循环都是变相的递归,因为当你重新进入那个循环时,你又重新进入了同一个循环(只是可能有不同的循环变量)。因此,这不像发明有关计算的新概念,更像是发现基础并使其明确。
因此,递归是自然的。我们只是写下一些关于我们的问题的定律,一些涉及我们正在定义的函数的方程,它们保留了一些不变量(假设函数是连贯定义的),用简化的术语重新指定问题,瞧!我们有解决方案。
一个例子,一个计算列表长度的函数(一种归纳定义的递归数据类型)。假设它已定义,并返回列表的长度,这并不奇怪。它必须遵守哪些法律?在问题的何种简化下保留了哪些不变量?
最直接的是将列表拆分为其头元素,其余部分 - 即列表的尾部(根据列表的定义/构造方式)。法律是,
length (x:xs) = 1 + length xs
啊!但是空列表呢?一定是这样
length [] = 0
那么我们如何编写这样的函数呢?……等等……我们已经写好了!(在 Haskell 中,如果你想知道,函数应用是通过并列表达的,括号仅用于分组,并且(x:xs)
是一个列表,其中包含x
第一个元素,xs
其余元素)。
允许这种编程风格的语言所需要的只是它具有TCO(也许有点奢侈, TRMCO),所以没有堆栈爆炸,我们已经准备好了。
另一件事是纯度 - 代码变量和/或数据结构(记录的字段等)的不变性。
这样做的作用是,除了让我们的大脑不必跟踪何时发生变化之外,它是否使时间在我们的代码中明确显示,而不是隐藏在我们“变化”的可变变量/数据中。从现在开始,我们只能在命令式代码中“改变”变量的值——过去我们不能很好地改变它的值,不是吗?
因此,我们最终得到了记录的更改历史列表,更改在代码中明确显示:而不是x := x + 2
我们编写let x2 = x1 + 2
. 它使推理代码变得容易得多。
为了使用TCOlength
解决尾递归上下文中的不变性,请考虑在累加器参数范式下对上述函数的尾递归重写:
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 中,这被称为受保护的递归或核心递归(至少我认为是这样)。