我将把你的问题理解为,更具体地说,“如果我已经使用 C 或 Java 等命令式语言进行编程,我该如何调整我的函数式编程思维?”以你的问题为例,我将在周六早上用长篇回答这个问题。我将通过三个阶段来追溯函数式程序员的演变,每个阶段都是禅宗的更高层面 - 1) 迭代地思考; 2) 递归思考; 3) 懒惰地思考。
第一部分 - 反复思考
假设我正在用 C 语言编程,但我不能或不会使用递归 - 也许编译器没有优化尾递归,递归解决方案会溢出堆栈。所以我开始思考我需要保持什么状态。我想象有一台小机器爬过输入。它会记住它是在搜索递增序列还是递减序列。如果它还没有决定,它会根据当前的输入来决定,如果可以的话。如果它发现输入指向错误的方向,它会以 zigzag=true 终止。如果到达输入的末尾,则以 zigzag=false 终止。
int
zigzag(int *data, int n)
{
enum {unknown, increasing, decreasing} direction = unknown;
int i;
for (i = 1; i < n; ++i)
{
if (data[i] > data[i - 1]) {
if (direction == decreasing) return 1;
direction = increasing;
}
if (data[i] < data[i - 1]) {
if (direction == increasing) return 1;
direction = decreasing;
}
}
/* We've made it through the gauntlet, no zigzagging */
return 0;
}
这个程序是典型的 C 程序:它很高效,但很难证明它会做正确的事情。即使对于这个简单的示例,也不能立即看出它不会陷入无限循环,或者在某个地方的逻辑出现错误。当然,更复杂的程序会变得更糟。
第二部分 - 递归思考
我发现,以函数式语言的精神(而不是仅仅试图将命令式解决方案转变为该语言)编写可读程序的关键是关注程序应该计算什么而不是而不是如何它应该做到这一点。如果你能以足够的精度做到这一点——如果你能清楚地写出问题——那么在函数式编程中的大部分时间里,你几乎已经找到了解决方案!
所以让我们从写出要更详细计算的内容开始。我们想知道一个列表是否曲折(即在某个点减少,在另一个点增加)。哪些列表符合此标准?好吧,如果:
- 它的长度超过两个元素并且
- 它最初会增加,但随后会在某个点减少或
- 它最初会减少,但随后会在某个点增加或
- 它的尾巴曲折。
可以或多或少地直接将上述语句翻译成一个 Scheme 函数:
(define (zigzag xs)
(and (> (length xs) 2)
(or (and (initially-increasing xs) (decreases xs))
(and (initially-decreasing xs) (increases xs))
(zigzag (cdr xs)))))
现在我们需要定义initially-increasing、initially-decreasing、decreases 和increases。 initially- 函数很简单:
(define (initially-increasing xs)
(> (cadr xs) (car xs)))
(define (initially-decreasing xs)
(< (cadr xs) (car xs)))
decreases 和 increases 呢?好吧,如果序列的长度大于一个,并且第一个元素大于第二个元素,或者它的尾部减少,则序列会减少:
(define (decreases xs)
(letrec ((passes
(lambda (prev rest)
(cond ((null? rest) #f)
((< (car rest) prev)
#t)
(else (passes (car rest) (cdr rest)))))))
(passes (car xs) (cdr xs))))
我们可以编写一个类似的increases 函数,但很明显只需要进行一项更改:< 必须变为>。复制这么多代码会让你感到不安。难道我不能让语言让我成为像decreases 这样的函数,而是在那个地方使用> 吗?在函数式语言中,您完全可以这样做,因为函数可以返回其他函数!因此我们可以编写一个函数来实现:“给定一个比较运算符,如果该比较对其参数的任何两个连续元素都为真,则返回一个返回真的函数。”
(define (ever op)
(lambda (xs)
(letrec ((passes
(lambda (prev rest)
(cond ((null? rest) #f)
((op (car rest) prev)
#t)
(else (passes (car rest) (cdr rest)))))))
(passes (car xs) (cdr xs)))))
increases 和 decreases 现在都可以非常简单地定义:
(define decreases (ever <))
(define increases (ever >))
没有更多要实现的功能 - 我们完成了。这个版本相对于 C 版本的优势是显而易见的——更容易推断这个程序会做正确的事情。该程序的大部分内容都非常简单,所有复杂性都被推到了ever 函数中,这是一个非常通用的操作,在许多其他上下文中都很有用。我确信通过搜索可以找到一个标准的(因此更值得信赖的)实现,而不是这个自定义的实现。
虽然有所改进,但该程序仍不完美。有很多自定义递归,起初并不明显所有这些都是tail recursive(尽管它是)。此外,该程序以多个条件分支和退出点的形式保留了 C 的微弱回声。在惰性评估的帮助下,我们可以得到更清晰的实现,为此我们将切换语言。
第三部分 - 懒惰地思考
让我们回到问题定义。它实际上可以比第二部分更简单地说明 - “如果序列包含在两个方向上的相邻元素之间的比较,则它是曲折的(即未排序的)”。我可以或多或少直接将那句话翻译成一行 Haskell:
zigzag xs = LT `elem` comparisons && GT `elem` comparisons
现在我需要一种方法来导出comparisons,这是xs 的每个成员与其后继者的比较列表。这并不难做到,也许最好通过例子来解释。
> xs
[1,1,1,2,3,4,5,3,9,9]
> zip xs (tail xs)
[(1,1),(1,1),(1,2),(2,3),(3,4),(4,5),(5,3),(3,9),(9,9)]
> map (\(x,y) -> compare x y) $ zip xs (tail xs)
[EQ,EQ,LT,LT,LT,LT,GT,LT,EQ]
这就是我们所需要的;这两行是完整的实现 -
zigzag xs = LT `elem` comparisons && GT `elem` comparisons
where comparisons = map (\(x,y) -> compare x y) $ zip xs (tail xs)
我会注意到,这个程序只通过列表一次来测试增加和减少的情况。
到此为止,您可能已经想到了一个反对意见:这种方法是不是很浪费?这不是要搜索整个输入列表,而它只需要到达第一个方向变化吗?实际上,不,它不会,因为懒惰的评估。在上面的示例中,它计算了整个比较列表,因为它必须打印出来。但是,如果它将结果传递给zigzag,它只会评估比较列表,以找到GT 的一个实例和LT 的一个实例,而不会进一步。为了让自己相信这一点,请考虑以下案例:
> zigzag $ 2:[1..]
True
> zigzag 1:[9,8..]
True
两种情况下的输入都是一个无限列表([2,1,2,3,4,5..] 和 [1,9,8,7,6,5...])。尝试将它们打印出来,它们将填满屏幕。但是将它们传递给zigzag,它会很快返回,只要它发现第一个方向变化。
阅读代码的许多困难来自于遵循控制流的多个分支。许多这些分支确实是在努力避免计算超出我们的需要。但是通过惰性求值可以实现很多相同的目标,从而使程序更短,更真实地反映原始问题。