惰性评估如何迫使 Haskell 成为纯粹的

How lazy evaluation forced Haskell to be pure

我记得看过一个演示文稿,其中 SPJ 说惰性求值迫使他们保持 Haskell 纯(或类似的东西)。我经常看到很多 Haskell 人说同样的话。

所以,我想了解懒惰的评估策略如何迫使他们保持 Haskell 纯粹而不是严格的评估策略?

这取决于您在此上下文中对 "pure" 的意思。

  • 如果对于 pure 你的意思是 purely functional,那么 @MathematicalOrchid 是真的:惰性求值你不知道不纯的动作是按什么顺序执行的,因此你根本无法编写有意义的程序,你被迫变得更纯(使用 IO monad)。

    但是我发现在这种情况下这并不是很令人满意。真正的函数式语言已经将纯代码和不纯代码分开,因此即使是严格的 也应该 具有某种 IO.

  • 然而,该陈述可能更广泛并且指的是纯粹的事实,即您能够更轻松地以更具声明性、组合性且更有效的方式表达代码。

正在查看 Hughes 的 this answer which makes exactly the statement you are citing it links to the article Why Functional Programming Matters,这可能就是您正在谈论的那个。

论文展示了高阶函数惰性求值如何允许编写更多模块化代码。请注意,它 没有 说任何关于纯功能等的内容。它的重点是在不损失效率的情况下更加模块化和更具声明性。

文章提供了一些示例。例如 Newton-Raphson 算法:在一种严格的语言中,您必须将计算下一个近似值的代码与检查您是否获得足够好的解决方案的代码组合在一起。

通过惰性求值,您可以在一个函数中生成无限的近似值列表,并从另一个函数调用它,returns 第一个足够好的近似值。


Haskell 的功能思考中,Richard Bird 正是指出了这一点。如果我们看第 2 章,练习 D:

Beaver is an eager evaluator, while Susan is a lazy one.

[...]

What alternative might Beaver prefer to head . filter p . map f?

答案是:

[...] Instead of defining first p f = head . filter p . map f, Beaver might define

first :: (b -> Bool) -> (a -> b) -> [a] -> b
first p xs | null xs = error "Empty list"
           | p x = x
           | otherwise = first p f (tail xs)
           where x = f (head xs)

The point is that with eager evaluation most functions have to be defined using explicit recursion, not in terms of useful component functions like map and filter.

这里的“如此纯粹”意味着允许声明式、组合式但高效的定义,而使用声明式和组合式定义的急切求值可能会导致不必要的低效代码。

与其说是懒惰的评价 导致 纯洁; Haskell 一开始就是纯粹的。相反,惰性求值迫使语言的设计者保持语言的纯净。

这是文章A History of Haskell: Being Lazy with Class中的相关段落:

Once we were committed to a lazy language, a pure one was inescapable. The converse is not true, but it is notable that in practice most pure programming languages are also lazy. Why? Because in a call-by-value language, whether functional or not, the temptation to allow unrestricted side effects inside a “function” is almost irresistible.

Purity is a big bet, with pervasive consequences. Unrestricted side effects are undoubtedly very convenient. Lacking side effects, Haskell’s input/output was initially painfully clumsy, which was a source of considerable embarrassment. Necessity being the mother of invention, this embarrassment ultimately led to the invention of monadic I/O, which we now regard as one of Haskell’s main con- tributions to the world, as we discuss in more detail in Section 7.

Whether a pure language (with monadic effects) is ultimately the best way to write programs is still an open question, but it certainly is a radical and elegant attack on the challenge of programming, and it was that combination of power and beauty that motivated the designers. In retrospect, therefore, perhaps the biggest single benefit of laziness is not laziness per se, but rather that laziness kept us pure, and thereby motivated a great deal of productive work on monads and encapsulated state.

(我的重点)

我也请你听听 Software Engineering Radio podcast #108 for an explanation by the man himself. And here is a longer but relevant passage from SPJ's interview in Peter Seibel's Coders at Work 的 18'30'':

I now think the important thing about laziness is that it kept us pure. [...]

[...] if you have a lazy evaluator, it’s harder to predict exactly when an expression is going to be evaluated. So that means if you want to print something on the screen, every call-by-value language, where the order of evaluation is completely explicit, does that by having an impure “function”—I’m putting quotes around it because it now isn’t a function at all—with type something like string to unit. You call this function and as a side effect it puts something on the screen. That’s what happens in Lisp; it also happens in ML. It happens in essentially every call-by-value language.

Now in a pure language, if you have a function from string to unit you would never need to call it because you know that it just gives the answer unit. That’s all a function can do, is give you the answer. And you know what the answer is. But of course if it has side effects, it’s very important that you do call it. In a lazy language the trouble is if you say, “f applied to print "hello",” then whether f evaluates its first argument is not apparent to the caller of the function. It’s something to do with the innards of the function. And if you pass it two arguments, f of print "hello" and print "goodbye", then you might print either or both in either order or neither. So somehow, with lazy evaluation, doing input/output by side effect just isn’t feasible. You can’t write sensible, reliable, predictable programs that way. So, we had to put up with that. It was a bit embarrassing really because you couldn’t really do any input/output to speak of. So for a long time we essentially had programs which could just take a string to a string. That was what the whole program did. The input string was the input and result string was the output and that’s all the program could really ever do.

You could get a bit clever by making the output string encode some output commands that were interpreted by some outer interpreter. So the output string might say, “Print this on the screen; put that on the disk.” An interpreter could actually do that. So you imagine the functional program is all nice and pure and there’s sort of this evil interpreter that interprets a string of commands. But then, of course, if you read a file, how do you get the input back into the program? Well, that’s not a problem, because you can output a string of commands that are interpreted by the evil interpreter and using lazy evaluation, it can dump the results back into the input of the program. So the program now takes a stream of responses to a stream of requests. The stream of requests go to the evil interpreter that does the things to the world. Each request generates a response that’s then fed back to the input. And because evaluation is lazy, the program has emitted a response just in time for it to come round the loop and be consumed as an input. But it was a bit fragile because if you consumed your response a bit too eagerly, then you get some kind of deadlock. Because you’d be asking for the answer to a question you hadn’t yet spat out of your back end yet.

The point of this is laziness drove us into a corner in which we had to think of ways around this I/O problem. I think that that was extremely important. The single most important thing about laziness was it drove us there.

(我的重点)

严格来说,这种说法是不正确的,因为 Haskell 有 unsafePerformIO,这是该语言功能纯度的一个大漏洞。 (它利用了 GHC Haskell 的功能纯度中的一个漏洞,这个漏洞最终可以追溯到通过向语言中添加一个严格的片段来实现未装箱算术的决定)。 unsafePerformIO 存在是因为大多数程序员无法抗拒说 "well, I'll implement just this one function using side-effects internally" 的诱惑。但是如果你看看 unsafePerformIO[1] 的缺点,你就会明白人们在说什么:

  • unsafePerformIO a 不保证会执行 a.
  • 也不保证只执行一次a,如果执行的话。
  • 也不保证 a 执行的 I/O 与程序其他部分的相对顺序。

这些缺点使 unsafePerformIO 主要限于最安全和最谨慎的使用,并且 为什么 人们会直接使用 IO 直到变得太不方便.

[1] 除了类型不安全; let r :: IORef a; r = unsafePerformIO $ newIORef undefined给你一个多态r :: IORef a,可以用来实现unsafeCast :: a -> b。 ML 有一个参考分配的解决方案可以避免这种情况,如果纯度不被认为是可取的,Haskell 可以用类似的方式解决它(无论如何,单态限制几乎是一个解决方案,你只需要禁止人们从通过使用类型签名来解决它,就像我上面所做的那样)。

我认为 Jubobs 的回答已经很好地总结了这一点(有很好的参考资料)。但是,用我自己的话说,我认为 SPJ 和朋友们指的是:

必须完成这项 "monad" 业务有时真的很不方便。 Stack Overflow 上询问 "how do I just remove this IO thing?" 的大量问题证明了这样一个事实,即有时,您真的 真的 只想在这里打印出这个值——通常是为了搞清楚到底是怎么回事!

在一种急切的语言中,开始添加神奇的不纯函数是非常诱人的,这些函数可以让你直接做不纯的事情,就像在其他语言中一样。毫无疑问,一开始您会从小事着手,但慢慢地,您会滑下这个湿滑的斜坡,不知不觉中,效果就无处不在了。

在像Haskell这样懒惰的语言中,这种诱惑仍然存在。很多时候 真的很有帮助 能够快速地在这里或那里偷偷地使用这个小效果。除了因为 懒惰 ,添加效果几乎毫无用处。当发生任何事情时,你无法控制。即使只是 Debug.trace 也会产生完全无法理解的结果。

简而言之,如果您要设计一种 惰性 语言,您确实 被迫 想出一个连贯的故事来说明如何你正在处理效果。你不能直接去 "meh, we'll pretend this function is just magic";如果没有更精确地控制效果的能力,您将立即陷入可怕的混乱之中!

TL;DR 在急切的语言中,你可以逃脱作弊。在惰性语言中,您确实必须 正确地做事,否则它根本行不通。

而且 这就是我们聘请 Alex 的原因——等等,错了 window...