关于haskell:列表生成函数的惰性求值?

Lazy evaluation for list generating functions?

我现在读的是格雷厄姆·赫顿在哈斯克尔的编程。

在第40页中,提出了一个玩具初级性试验:

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

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

然后作者继续解释

"deciding that a number is not prime does not require the function
prime to produce all of its factors, because under lazy evaluation the
result False is returned as soon as any factor other than one or the
number itself is produced"

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

这怎么可能?这是否会使解释程序执行顺序变得更加困难?


你发现它"令人震惊",因为你没有预料到它。一旦你习惯了…好吧,事实上,它仍然会让人绊倒。但过了一段时间,你最终会把你的思想包围起来。

haskell的工作原理是:当你调用一个函数时,什么都不会发生!电话被记在某个地方,就这样。这几乎不需要时间。你的"结果"实际上只是一个"我欠你"告诉计算机运行什么代码来获得结果。不是所有的结果,记住你,只是第一步。对于整数这样的东西,只有一个步骤。但是对于一个列表,每个元素都是一个单独的步骤。

让我给你举个简单的例子:

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

我跟一个C++程序员谈了一句,这让人感到震惊。"++[0]"部分必须"找到列表的结尾",然后才能向其追加零?这段代码如何在有限时间内完成?!

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

这就是实际情况。最外层的功能是take,所以我们从这里开始。(没想到,嗯?)take的定义如下:

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

如此清晰的10!=0,因此第一行不适用。所以第二行或第三行都适用。所以现在,take查看[1..] ++ [0],看它是一个空列表还是一个非空列表。

这里最外层的功能是(++)。它的定义看起来像

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

所以我们需要找出哪一个方程适用。左边的参数要么是一个空列表(应用第1行),要么不是(应用第2行)。既然[1..]是一个无限列表,第二行总是适用的。因此,[1..] ++ [0]的"结果"是1 : ([2..] ++ [0])。正如您所看到的,这并没有完全执行;但是执行的距离足以告诉您这是一个非空列表。这就是take所关心的。

1
2
3
4
5
6
7
8
9
10
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:

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

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

1
2
3
4
5
6
7
prime 6
factors 6 == [1,6]
??? == [1,6]
1 : ??? == [1,6]
??? == [6]
2 : ??? == [6]
False


我将集中讨论这一点:

Doesn't this make way more difficult to reason about the order of execution of a program?

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

1
(1 * 3) + (4 * 5)

问:先做哪一个乘法?A:我们不在乎,结果是一样的。即使C编译器也可以在这里选择任何顺序。

1
(f 1) + (f 2)

问:首先执行哪个函数调用?A:我们不在乎,结果是一样的。在这里,C编译器也可以选择任何顺序。然而,在C语言中,函数f可能会产生副作用,因此上述总和的结果取决于评价的顺序。在纯函数编程中,没有副作用,所以我们真的不在乎。

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

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

我们称之为f 2。结果应与e{2/x}相同,即e中,x的每一次(自由)发生都被2取代。这只是"展开定义",就像在数学中一样。例如,

1
2
3
f x = x + 4

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

然而,假设我们称之为f (g 2)。懒惰使这相当于e{g 2/x}。同样,在数学方面。例如:

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

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

这实际上使解释程序行为变得更简单。

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


Doesn't this make way more difficult to reason about the order of execution of a program?

可能——至少对于那些来自程序/OO范式的人来说。我用其他热切的评估语言对迭代器和函数编程做了很多工作,对我来说,懒惰的评估策略并不是学习haskell的主要问题。(你有多少次希望Java日志语句甚至在获得实际数据记录后才得到该消息的数据?)

把haskell中的所有列表处理想象成它被编译成一个基于迭代器的实现。如果你在Java中使用EDCOX1的11个因素作为EDCOX1的12个参数,那么当你发现一个不是EDCOX1、13或EDCOX1、11的那一个时,你不想停止吗?如果是这样,那么迭代器是否是无限的就不重要了!

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

    百万千克1结果的正确性百万千克1百万千克1及时终止百万千克1百万千克1任何副作用的相对顺序百万千克1

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

幸运的是(或者不幸的是,取决于你问谁),我们在Haskell中有Monad作为一种设计模式,它的目的(除其他外)是控制评估顺序,从而控制副作用。

但是,即使不了解monad和所有这些东西,它实际上也和程序语言一样容易理解执行顺序。你只要习惯就行了。