列表生成函数的延迟评估?

Posted

技术标签:

【中文标题】列表生成函数的延迟评估?【英文标题】:Lazy evaluation for list generating functions? 【发布时间】:2016-08-14 18:03:08 【问题描述】:

我目前正在阅读 Graham Hutton 的《Haskell 编程》。

在第 40 页中,提出了玩具素数测试:

factors :: Int -> [Int]
factors n = [x | x <- [1..n], n `mod` x == 0]

prime :: Int -> Bool
prime n = factors n == [1,n]

然后作者继续解释如何

"判断一个数不是素数不需要函数 产生其所有因数的素数,因为在惰性求值下 结果False 会在除一个或 数字本身产生”

作为一个来自 C 和 Java 的人,我觉得这令人震惊。我希望factors 调用首先完成,将结果保存在堆栈中并将控制权传递给调用函数。但显然这里正在执行一个非常不同的程序:factors 中的列表理解必须有一个循环,并且prime 中的相等性检查正在检查每个添加到因子列表中的新元素。

这怎么可能? 这不是更难推理程序的执行顺序吗?

【问题讨论】:

想想factors n == [1,n]是如何实现的,它将逐个元素地验证,因子n也将逐个元素地生成,只要第二个元素不等于任何更多的测试(以及更多的元素来自因素)不是必需的。 执行顺序对于纯函数来说不是一个大问题;像单子这样的概念用于确保需要按顺序发生的事情以正确的顺序发生。 【参考方案1】:

你觉得它“令人震惊”,因为你没有预料到它。一旦你习惯了它......好吧,实际上它仍然会绊倒人们。但过了一会儿,你最终还是会想到它。

Haskell 的工作原理是这样的:当你调用一个函数时,什么都没有发生!这个调用被记录在某个地方,仅此而已。这几乎不需要任何时间。你的“结果”实际上只是一个“我欠你的”,告诉计算机要运行什么代码才能得到结果。请注意,不是整个结果;只是它的第一步。对于像整数这样的东西,只有一步。但是对于列表,每个元素都是一个单独的步骤。

让我给你看一个更简单的例子:

print (take 10 ([1..] ++ [0]))

我与一位 C++ 程序员交谈过,他对这种方法感到“震惊”。当然,“++[0]”部分必须“找到列表的末尾”才能将零附加到它上面?这段代码怎么能在有限时间内完成?!

看起来像这会构建[1..](在无限列表中),然后++[0] 扫描到此列表的末尾并插入一个零,然后take 10 只修剪掉前 10 个元素,然后打印。当然,这花费无限的时间。

这就是实际发生的事情。 最外层函数take,这就是我们开始的地方。 (没想到吧?)take 的定义是这样的:

take 0 (   _) = []
take n (  []) = []
take n (x:xs) = x : (take (n-1) xs)

很明显 10 != 0,所以第一行不适用。所以第二行或第三行都适用。所以现在take 查看[1..] ++ [0] 看它是空列表还是非空列表。

这里最外层的函数是(++)。它的定义看起来类似于

(  []) ++ ys = ys
(x:xs) ++ ys = x : (xs ++ ys)

所以我们需要弄清楚哪个方程适用。左边的参数要么是一个空列表(第一行适用),要么不是(第二行适用)。好吧,因为[1..] 是一个无限列表,所以第二行总是 适用。所以[1..] ++ [0] 的“结果”是1 : ([2..] ++ [0])。如您所见,这并没有完全执行;但它执行得足够远,足以说明这是一个非空列表。 take 关心的就是这些。

take 10 ([1..] ++ [0])
take 10 (1 : ([2..] ++ [0]))
1 : take 9 ([2..] ++ [0])
1 : take 9 (2 : ([3..] ++ [0]))
1 : 2 : take 8 ([3..] ++ [0])
1 : 2 : take 8 (3 : ([4..] ++ [0]))
1 : 2 : 3 : take 7 ([4..] ++ [0])
...
1 : 2 : 3 : 4 : 5 : 6 : 7 : 8 : 9 : 10 : take 0 ([11..] ++ [0])
1 : 2 : 3 : 4 : 5 : 6 : 7 : 8 : 9 : 10 : []

你知道这是如何展开的吗?


现在,回到您的具体问题:(==) 运算符采用一对列表并遍历它们,逐个元素地比较它们以确保它们相等。 一旦发现差异,它立即中止并返回false:

(  []) == (  []) = True
(x:xs) == (y:ys) = (x == y) && (xs == ys)
(   _) == (   _) = False

如果我们现在尝试,比如说prime 6

prime 6
factors 6 == [1,6]
??? == [1,6]
1 : ??? == [1,6]
??? == [6]
2 : ??? == [6]
False

【讨论】:

好的。我不再震惊。现在我很害怕:D @MisterSmith 这需要习惯,但它非常有用。这意味着您可以将如何生成内容的逻辑与决定使用多少内容的逻辑解耦。你的生产者只是返回一个“无限”的东西列表,你的消费者决定消费多少。这是一种完全不同的代码结构方式。 (这么多,C# 和它的同类现在拥有整个 yield return 的东西来复制它。) 严格来说print是最外层的函数。当然,没有它,该示例也可以正常工作。很好的答案! @user2407038 严格来说,它是 Haskell 运行时系统,它评估 print 并执行生成的 I/O 命令对象......但我们先不谈这个。 ;)【参考方案2】:

我将重点介绍这一点:

这不是更难推理程序的执行顺序吗?

是的,但是在纯函数式编程中,求值的顺序并不重要。例如:

(1 * 3) + (4 * 5)

问:先执行哪个乘法? A:我们不管,结果都是一样的。甚至 C 编译器也可以在这里选择任何顺序。

(f 1) + (f 2)

问:首先执行哪个函数调用? A:我们不管,结果都是一样的。在这里,C 编译器也可以选择任何顺序。但是,在 C 语言中,函数f 可能有副作用,使得上述求和的结果取决于评估的顺序。在纯函数式编程中,没有副作用,所以我们真的不在乎。

此外,惰性允许任何函数定义的语义保持扩展。假设我们定义

f x = e -- e is an expression which can use x

我们打电话给f 2。结果应该与e2/x 相同,即与e 相同,其中x 的每个(免费)出现都已替换为2。这只是“展开定义”,就像在数学中一样。例如,

f x = x + 4

-- f 2 ==> 2 + 4 ==> 6

但是,假设我们改为调用 f (g 2)。懒惰使这相当于eg 2/x。再次,就像在数学中一样。例如:

f x = 42
g n = g (n + 1)  -- infinite recursion

那么我们仍然f (g 2) = 42 g 2/x = 42,因为x没有被使用。我们不必担心是否定义了g 2(永远循环)。展开的定义总是有效。

这实际上使得推理程序行为更简单

不过,懒惰也有一些缺点。一个主要的问题是,虽然程序的语义(可以说)更简单,但估计程序的性能更难。要评估性能,您必须了解的不仅仅是最终结果:您需要有一个模型,其中包含导致该结果的所有中间步骤。尤其是在高级代码中,或者当一些巧妙的优化开始时,这需要一些关于运行时实际工作方式的专业知识。

【讨论】:

“是的,但是在纯函数式编程中执行顺序并不重要。”这种说法可能会让那些一开始就不懂纯函数式编程的人感到困惑。纯函数式编程在求值效果之间做出了明确的、编译器强制的区别。评估的顺序并不重要,而效果的顺序确实很重要。在这些示例中,我们讨论的是纯函数的求值,因此无需考虑任何影响。 @LuisCasillas 我可以同意,但提到效果、单子以及所有这些来回答一个根本不使用它们的问题也可能令人困惑。我将“执行”替换为“评估”,以减轻损失。 我明白,但我不相信这不会导致进一步的混乱。【参考方案3】:

这不是更难推理程序的执行顺序吗?

可能 - 至少对于那些来自程序/OO 范式的人来说。我已经在其他急切评估语言中使用迭代器和函数式编程做了很多工作,对我来说,惰性评估策略并不是学习 Haskell 的主要问题。 (有多少次你希望你的 Java 日志语句在决定实际记录它之前甚至不会获取消息的数据?)

考虑一下 Haskell 中的所有列表处理,就好像它被编译成一个基于迭代器的实现一样。如果您在 Java 中使用n 的可能因素作为Iterator&lt;Integer&gt; 来执行此操作,您是否不想在找到不是1n 的因素后立即停止?如果是这样,迭代器是否无限并不重要!

当你认真对待它时,执行的顺序并不重要。你真正关心的是:

结果的正确性 及时终止 任何副作用的相对顺序

现在,如果您有一个“纯功能”程序,则没有副作用。但那什么时候发生呢?除了直接数字/字符串处理和元代码(即高阶函数)之外,几乎任何有用的东西都会产生副作用。

幸运的是(或者不幸的是,取决于你问谁),我们在 Haskell 中将monad 作为一种设计模式,其目的是(除其他外)控制评估顺序,并因此产生副作用。

但即使没有学习 monad 和所有这些东西,它实际上也很容易推理执行顺序,就像在程序语言中一样。你只需要习惯它。

【讨论】:

以上是关于列表生成函数的延迟评估?的主要内容,如果未能解决你的问题,请参考以下文章

Python高阶函数

生成器知识点整理

生成器中yield和next()的用法解析

Python 帮助延迟加载大型数据集

ldc

Python函数编程——列表生成式和生成器