懒惰和纯洁之间有什么联系?

45

25
写出“不纯的惰性代码”非常困难,因为你无法保证副作用会以什么顺序发生。选择采用惰性求值策略可以避免混入不纯代码的诱惑,并迫使早期Haskell开发者发明了处理副作用的纯函数式模式,例如单子IO。 - Joe
2
这种“诱惑”的现代评估应该是夸大其词的。作为严格纯净语言,Idris和Purescript都没有必要处理任何此类问题。 - András Kovács
4
在一条跟进的推文中,作者解释道:“如果一种语言是惰性求值的,通常它必须是纯函数式的,因为在惰性求值的语言中,表达式的求值顺序很难预测,这使得管理副作用比正常情况下更加困难”。 - Fabio says Reinstate Monica
似乎与此处有关。 - Will Ness
3个回答

35
你说得对,从现代的角度来看这确实没有太多意义。真正的情况是,懒惰默认会让处理有副作用的代码变得非常困难,因此懒惰确实需要纯度——但反过来并不成立。
然而,要求懒惰的是Haskell 1.0-1.2版本中模拟IO的方式,这种方式遵循了其前身Miranda的做法,在缺乏任何明确的副作用排序概念的情况下,可执行程序的类型为
main :: [Response] -> [Request]

对于一个简单的交互式程序,它将会工作得像这样:main 一开始会忽略其输入列表。所以由于惰性求值,那个列表中的值实际上在那个时刻并不需要存在。同时,它会产生第一个 Request 值,例如要求用户输入的终端提示。然后键入的内容将会作为一个 Response 值返回,现在才需要对其进行评估,从而产生一个新的 Request 等等。

https://www.haskell.org/definition/haskell-report-1.0.ps.gz

在版本1.3中,他们转向了我们今天所熟知和喜爱的单子IO接口,此时懒惰已不再必要。但在此之前,普遍认为与真实世界交互的唯一方法是允许具有副作用的函数,因此没有懒惰,Haskell将沿着Lisp和ML走过的道路下滑。{{}}

10
我不确定第一段在说什么。懒惰等于纯洁,因此如果我们致力于使Haskell变得懒惰,就相当于致力于使它变得纯洁。如果Haskell是严格的,那么没有要求它必须是不纯的,但语言实现者可能会屈服于诱惑并开始允许不纯度,因为这是可能的。这个声明不仅仅是关于语言设计本身,而是关于我们人类的评论。 - HTNW
2
@HTNW 机器人设计和使用的语言吗? - user253751

28

这条推文包含两个方面:首先,从技术角度来看,懒惰通常要求纯净;其次,实际上,严格要求可能仍然可以保证纯净,但在实践中通常无法做到(即,在严格性的情况下,纯度“荡然无存”)。

Simon Peyton-Jones在论文《Haskell的历史:具有类的懒惰》中解释了这两个方面。关于技术方面,在第3.2节“Haskell是纯净的”中,他写道(我加粗):

懒惰的一个直接结果是评估顺序是需求驱动的。因此,在函数调用的结果中执行input/output或其他副作用就变得多多少少不可靠。因此,Haskell是一种纯语言。

如果您不明白为什么懒惰会使不纯的效应不可靠,我相信这是因为您想得太多。以下是一个简单的示例,说明问题所在。考虑一个假设的不纯函数,它从配置文件中读取一些信息,即一些“基本”配置和一些“扩展”配置,其格式取决于标头中的配置文件版本信息:

getConfig :: Handle -> Config
getConfig h =
  let header = readHeader h
      basic = readBasicConfig h
      extended = readExtendedConfig (headerVersion header) h
  in Config basic extended

readHeaderreadBasicConfigreadExtendedConfig是所有不纯函数,它们按顺序从文件中读取字节(即使用典型的基于文件指针的顺序读取)并将其解析为适当的数据结构。

在惰性语言中,该函数可能无法按预期工作。如果headerbasicextended变量值都是惰性求值的,则如果调用者先强制执行basic,然后是extended,效果将按顺序调用readBasicreadHeaderreadExtendedConfig;而如果调用者先强制执行extended,然后是basic,则效果将按顺序调用readHeaderreadExtendedConfigreadBasic。在任一情况下,本来要由一个函数解析的字节将被另一个函数解析。

而且,这些评估顺序是过度简化的,假设子函数的效果是“原子”的,并且readExtendedConfig可靠地强制对extended的任何访问的版本参数。如果没有,根据强制执行basicextended的哪些部分,(子)效果在readBasicreadExtendedConfigreadHeader中的顺序可能被重新排序和/或交织。

您可以通过禁止顺序文件访问(尽管这会带来一些重大成本!)解决此特定限制,但类似的不可预测的乱序效果执行将导致其他I/O操作(如何确保文件更新函数在截断文件以进行更新之前读取旧内容?),可变变量(锁变量什么时候确切地增加?)等问题。

在实际方面(再次强调我的粗体),SPJ写道:

一旦我们致力于使用惰性语言,语言就是不可避免的。反之则不然,但值得注意的是,在实践中,大多数纯编程语言也是惰性的。为什么?因为在按值调用的语言中,无论是功能性的还是非功能性的,“在”函数 “内允许不受限制的副作用”的诱惑几乎是不可抗拒的。

...

因此,回顾起来,惰性的最大单一好处可能不是惰性本身,而是惰性使我们保持了纯洁,并且激励大量关于单子和封装状态的有益工作。

在他的推文中,我认为Hutton所指的不是惰性导致纯度的技术后果,而是严格性诱导语言设计者在“只有这一个特殊情况”之后放松纯度,之后纯度很快消失。


9
即使是 Haskell 的实现者们也屈服于诱惑,通过 unsafePerformIO 允许在计算中进行不受限制的副作用 - 但是延迟计算让大家不敢真正利用它来构建这些不纯的程序,因为它们不能依赖于计算顺序。 - Bergi

17
其他答案提供了最有可能与该评论相关的历史背景。我认为这种联系甚至更深刻。 热心的语言,即使是那些自称“纯粹”的语言,在Haskell中并没有像引用透明度那样强的概念。
let f = E in
\x -> f x

不等同于

\x -> E x

如果前面的表达式被急切地求值并且 E 的求值发散,则会出现问题。

急切语言需要区分值和计算:变量只能替换为值,但表达式代表计算,这就是为什么上面的“显而易见”的 let 缩减不合法的原因。表达式超越其所指表示的值,这正是语言具有效果的含义。从这个非常技术性的意义上讲,像 Purescript 这样的急切语言(我所能想到的第一个例子)并不是纯粹的。

当我们忽略非终止和计算顺序时,Purescript 是纯粹的,这也是几乎所有程序员所做的,值得称赞。

相比之下,在懒惰语言中,值和计算之间的区别变得模糊起来。一切都代表值,即使是非终止的表达式,它们也是“底部”的。您可以随心所欲地替换而不必担心表达式的计算过程。在我看来,这正是纯粹的要点。

有人可能会争辩说,在急切语言中,一个发散的表达式实际上也表示为底部,只是 let 表示了一个严格函数。说实话,这可能是一种良好的后验解释,但除了 Haskeller 和编程语言恐怖分子外,没有人会这样想。


13
我猜你在最后一句话中实际上是想说“编程语言理论家”,但是我能想到一些人可以称之为“编程语言恐怖分子”! - Glenn Willen
3
你的例子提出了一个合理的观点,但实际上它的实际意义有限,因为Haskell实际上存在着几乎相同的问题,尽管它是一种惰性求值的语言:在let f=E in \x->f x中,E只会被评估一次,而在\x->E x中,它将在每个函数调用时被反复评估。这不像严格求值的情况下得到的 ⊥s 那么明显,但对于程序员来说仍然非常重要。实际上,对于每个出现的情况选择更好的编写方式并不是太大的问题。 - leftaroundabout
1
你可以认为从操作语义的角度来看这只是一个技术细节,但我认为它不再仅涉及纯函数正确性这一事实深刻地影响了你对程序的推理方式以及编译器如何优化它们。因此,我认为实际相关性并不受限制。使用 fun () -> 延迟计算是表达许多急切程序的必要条件。在 Haskell 中,是的,你必须意识到操作上的问题才能完全理解某些东西的运行方式,但它不会经常出现在语法中,这进一步鼓励了指称式思维。 - Li-yao Xia

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接