【问题标题】:Does a function in Haskell always evaluate its return value?Haskell 中的函数是否总是评估其返回值?
【发布时间】:2014-12-29 07:45:42
【问题描述】:

我试图更好地理解 Haskell 的惰性,例如当它评估函数的参数时。

来自source

但是当对const 的调用被求值时(这是我们感兴趣的情况,毕竟这里),它的返回值也被求值......这是一个很好的一般原则:一个函数显然是严格的它的返回值,因为当需要评估函数应用程序时,它需要在函数体中评估返回的内容。从那里开始,您可以通过查看返回值始终依赖的内容来了解​​必须评估的内容。你的函数在这些参数上是严格的,而在其他参数上是惰性的。

所以 Haskell 中的函数总是计算自己的返回值?如果我有:

foo :: Num a => [a] -> [a]
foo [] = []
foo (_:xs) = map (* 2) xs

head (foo [1..]) -- = 4

根据上面的段落,map (* 2) xs,必须被评估。直观地说,我认为这意味着将 map 应用于整个列表 - 导致无限循环。 但是,我可以成功地拿下结果。我知道 : 在 Haskell 中是懒惰的,所以这是否意味着评估 map (* 2) xs 只是意味着构建其他尚未完全评估的东西?

对应用于无限列表的函数求值是什么意思?如果函数的返回值总是在函数被求值时被求值,那么函数真的可以返回一个 thunk 吗?

编辑:

bar x y = x
var = bar (product [1..]) 1

此代码不会挂起。当我创建var 时,它不会评估它的主体吗?还是将bar 设置为product [1..] 而不对其进行评估?如果是后者,bar 不会在 WHNF 中返回其主体,对,那么它真的“评估”了 x 吗?如果 bar 不挂在计算 product [1..] 上,bar 怎么可能对 x 严格?

【问题讨论】:

  • @AndrewC 答案(通常)是肯定的。这当然是 ghc 的答案。 foo 函数不返回 thunk。
  • @AndrewC 当我们谈论 Haskell 时,“评估”几乎总是意味着“评估到 WHNF”。否则我们会说“全面评估”之类的。
  • @augustss: foo x y = x 在 x 中是严格的,并且(可能)即使在 -O0 也不会返回 thunk,但一般情况下并非如此。正如您所说,有必要查看如何要求返回值。
  • 函数foo x y = xx中是绝对严格的。严格具有数学定义并且独立于实现。

标签: haskell lazy-evaluation


【解决方案1】:

首先,Haskell 没有指定评估发生的时间,所以这个问题只能针对具体的实现给出明确的答案。

以下内容适用于我所知道的所有非并行实现,例如 ghc、hbc、nhc、hugs 等(所有基于 G 机器,顺便说一句)。

顺便说一句,要记住的是,当您听到 Haskell 的“评估”时,它通常意味着“评估到 WHNF”。

与严格的语言不同,您必须区分函数的两个“调用者”,第一个是在词法上发生调用的地方,第二个是需要值的地方。对于严格的语言,这两者总是一致的,但对于惰性语言则不然。 让我们以你的例子为例,稍微复杂一点:

foo [] = []
foo (_:xs) = map (* 2) xs

bar x = (foo [1..], x)

main = print (head (fst (bar 42)))

foo 函数出现在 bar 中。评估bar 将返回一个pair,该pair 的第一个组件是对应于foo [1..] 的thunk。所以bar 是严格语言的调用者,但在惰性语言的情况下,它根本不调用foo,而是构建闭包。

现在,在main 函数中,我们实际上需要head (fst (bar 42)) 的值,因为我们必须打印它。所以head 函数实际上会被调用。 head 函数是通过模式匹配定义的,所以它需要参数的值。所以fst 被调用。它也是由模式匹配定义的,需要它的参数,所以bar 被调用,bar 将返回一对,fst 将评估并返回它的第一个组件。现在终于foo 被“调用”了;并且通过调用我的意思是评估thunk(输入,因为它有时在TIM术语中被称为),因为需要该值。调用foo 的实际代码的唯一原因是我们想要一个值。所以foo 最好返回一个值(即 WHNF)。 foo 函数将评估其参数并最终进入第二个分支。在这里,它将尾调用map 的代码。 map 函数由模式匹配定义,它将评估其参数,这是一个缺点。所以 map 会返回下面的{(*2) y} : {map (*2) ys},这里我用{} 表示正在构建一个闭包。如您所见,map 只返回一个 cons 单元格,头部为闭包,尾部为闭包。

为了更好地理解 Haskell 的操作语义,我建议你看一些论文,描述如何将 Haskell 翻译成一些抽象机器,比如 G 机器。

【讨论】:

  • 感谢您的回复。那么,foo x y = xx 中是严格的,还是在任何论点中都不严格?而且,您提到当我们最终输入foo 时,它也进入了map 的主体,但这会返回{(*2) y} : {map (*2) ys}。如果我们从foo, 进入map 的主体,为什么不在{(*2) y} : {map (*2) ys} 的尾部再次输入map?为什么我们要返回一个带有map 调用的thunk,而不是在foo 中更早地返回一个thunk - 是因为它确实评估其返回类型,并将其放入WHNF ?
  • 函数foo x y = xx中是严格的。所有函数都会评估其结果。所以调用foo 必须返回一个WHNF。调用map 将完成此操作。另一种方法是构建调用map 的thunk,然后立即对其进行评估。直接调用map 是对它的优化。
  • 我的问题的 cmets 中提出的一个对立点是 length [const (error "oops") 1] 返回 1- 不是错误,认为 const 因此在返回时不会评估其结果。我的猜测是这是错误的——const 确实评估了它的结果,但我们从未真正输入过const,因为length 在计算长度时不需要查看 thunk 的内容,对吗? ?
  • 非常感谢 cmets 和帮助。我只是想确保我理解-在您的帖子中,评估 bar 不会进入 foo 因为结果已经在 WHNF 中-但是当我们在 foo 中时,我们 做进入map,因为我们还在WHNF。对?总之,Haskell 中的函数(通常是 GHC)会评估它们的返回值(到 WHNF),并且它们在返回值所依赖的参数上是严格的。
  • @user2666425 是的。 bar 的结果在 WHNF 中(构造函数是 (,),但元组的两个组件都未计算除非/直到调用代码进入它们。对于foo,要知道什么你必须弄清楚你将匹配哪种模式,这迫使你将列表评估为 WHNF(它是空的还是它是一个缺点?),它是一个缺点 -> 第二个子句。事实上,因为 map (*2) xs不是 WHNF,我们必须走得足够远才能返回 [] 或一个缺点。所以我们需要评估 map 足以知道返回哪个列表构造函数。
【解决方案2】:

我总是发现,当我尝试将其应用于 Haskell 时,我在其他环境(例如,Scheme 编程)中学到的“评估”一词总是让我感到困惑,而我在开始时取得了突破以 强制 表达式而不是“评估”它们来考虑 Haskell。一些主要区别:

  • “评估”,正如我之前所学的,强烈表示将表达式映射到本身不是表达式的。 (这里的一个常见技术术语是“外延”。)
  • 在 Haskell 中,强制的过程在恕我直言,最容易理解为表达式重写。你从一个表达式开始,然后根据某些规则反复重写它,直到得到满足某个属性的等效表达式。

在 Haskell 中,“某些属性”具有不友好的名称​​弱头范式(“WHNF”),这实际上只是意味着表达式是空数据构造函数或数据的应用程序构造函数。

让我们将其转化为一组非常粗略的非正式规则。强制表达式expr

  • 如果expr是一个nullary构造函数或构造函数应用程序,强制它的结果是expr本身。 (它已经在 WHNF 中了。)
  • 如果expr是函数应用f arg,那么强制它的结果是这样得到的:
    1. 找到f的定义。
    2. 您能否将此定义与表达式arg 进行模式匹配?如果不是,则强制 arg 并根据结果重试。
    3. f 正文中的模式匹配变量替换为arg 中与它们对应的部分(可能重写),并强制生成表达式。

对此的一种思考方式是,当您强制使用表达式时,您会尝试最小程度地重写它以将其简化为 WHNF 中的等效表达式。

让我们将此应用于您的示例:

foo :: Num a => [a] -> [a]
foo [] = []
foo (_:xs) = map (* 2) xs

-- We want to force this expression:
head (foo [1..])

我们需要定义head 和`map:

head [] = undefined
head (x:_) = x

map _ [] = []
map f (x:xs) = f x : map f x

-- Not real code, but a rule we'll be using for forcing infinite ranges.
[n..] ==> n : [(n+1)..]

那么现在:

head (foo [1..]) ==> head (map (*2) [1..])       -- using the definition of foo
                 ==> head (map (*2) (1 : [2..])) -- using the forcing rule for [n..]
                 ==> head (1*2 : map (*2) [2..]) -- using the definition of map
                 ==> 1*2                         -- using the definition of head
                 ==> 2                           -- using the definition of *

【讨论】:

  • 那么,2 返回后,foo [1..] 的第一个元素是什么?是1*2,还是更新为2
  • 它是 2。通过图形缩减,术语是共享的。
【解决方案3】:

我相信这个想法一定是在惰性语言中,如果你正在评估一个函数应用程序,那一定是因为你需要应用程序的结果来做某事。因此,无论什么原因导致函数应用程序首先减少,都将继续需要减少返回的结果。如果我们不需要函数的结果,我们一开始就不会评估调用,整个应用程序将被保留为 thunk。

一个关键点是标准的“惰性评估”顺序是需求驱动的。你只评估你需要的东西。评估更多违反语言规范“非严格语义”定义的风险,以及一些应该能够终止的程序的循环或失败;惰性求值有一个有趣的特性,即如果任何求值顺序都可以导致特定程序终止,那么惰性求值也可以。1

但如果我们只评估我们需要什么,“需要”是什么意思?通常它意味着要么

  1. 模式匹配需要知道特定值是什么构造函数(例如,在不知道参数是 [] 还是 _:xs 的情况下,我无法知道在您的 foo 定义中采用哪个分支)
  2. 原始操作需要知道整个值(例如,CPU 中的算术电路无法添加或比较 thunk;我需要完全评估两个 Int 值才能调用此类操作)
  3. 执行main IO 操作的外部驱动程序需要知道接下来要执行的操作是什么

假设我们有这个程序:

foo :: Num a => [a] -> [a]
foo [] = []
foo (_:xs) = map (* 2) xs

main :: IO ()
main = print (head (foo [1..]))

要执行 main,IO 驱动程序必须评估 thunk print (head (foo [1..])) 以确定它是 print 应用于 thunk head (foo [1..])print 需要评估它的参数以便打印它,所以现在我们需要评估那个 thunk。

head 从模式匹配它的参数开始,所以现在我们需要评估foo [1..]但仅限于WHNF - 足以判断最外面的列表构造函数是[] 还是@ 987654335@.

foo 首先对其参数进行模式匹配。所以我们需要评估[1..],也只需要评估WHNF。基本上就是1 : [2..],这足以看出foo要取哪个分支。2

foo: 情况(xs 绑定到 thunk [2..])计算为 thunk map (*2) [2..]

所以foo 被评估,但没有评估它的主体。然而,我们这样做只是因为head 是模式匹配,以查看我们是否有[]x : _。我们仍然不知道,所以我们必须立即继续评估foo的结​​果。

是文章所说的函数在其结果中是严格的意思。 鉴于foo 的调用完全被评估,它的结果也将被评估(因此,评估结果所需​​的任何东西也将被评估)。

但是需要评估多远取决于调用上下文。 head 只是对foo 的结果进行模式匹配,因此它只需要一个结果到 WHNF。我们可以得到一个 WHNF 的无限列表(我们已经这样做了,使用1 : [2..]),所以在评估对foo 的调用时,我们不一定会进入无限循环。但是如果head 是在Haskell 之外实现的某种原始操作,需要传递一个完全评估的列表,那么我们将完全评估foo [1..],因此永远不会完成才能回到head .

所以,为了完成我的示例,我们正在评估 map (2 *) [2..]

map 模式匹配它的第二个参数,所以我们需要评估[2..] 直到2 : [3..]。这足以让map 返回 thunk (2 *) 2 : map (2 *) [3..],它在 WHNF 中。这样就完成了,我们终于可以返回head了。

head ((2 *) 2 : map (2 *) [3..]) 不需要检查: 的任一侧,它只需要知道有一个,以便它可以返回左侧。所以它只返回未评估的 thunk (2 *) 2

尽管如此,我们只评估了对head 的调用,因为print 需要知道它的结果是什么,所以尽管head 不评估它的结果,它的结果总是在调用head 是。

(2 *) 2 的计算结果为 4print 将其转换为字符串 "4"(通过 show),并将该行打印到输出。这就是整个main IO 操作,所以程序完成了。


1 Haskell 的实现,例如 GHC,并不总是使用“标准惰性求值”,语言规范也没有要求。如果编译器可以证明某些东西总是需要的,或者不能循环/错误,那么即使惰性评估(还)不会这样做,也可以安全地评估它。这通常会更快,因此 GHC 优化确实可以做到这一点。

2 我在这里跳过了一些细节,比如 print 确实有一些非原始的实现,我们可以进入并懒惰地评估,而 [1..] 可以进一步扩展到实际实现该语法的函数。

【讨论】:

  • 在您的解释中,为什么 foo 返回一个 map (* 2) [2..] 的 thunk 而不是将其评估为 WHNF 本身?
  • @user2666425 为什么会这样?它不会对自己的结果进行模式匹配,因此它不需要知道它返回的构造函数。当然,它可以作为一种优化,因为无论何时调用它来返回任何东西,它的结果都会被评估。
  • 所以一般来说没有保证函数在 WHNF 中返回其结果?实际上,如果您查看我的问题中的 cmets,有人会说 '这肯定是 ghc 的答案。 foo 函数不返回 thunk'。好困惑。
  • @user2666425 我的回答更多的是理论而不是实际实现。这个理论比 GHC 实际上简单得多,而且大多数时候你只需要了解即可预测事情的运作方式。关键是评估 foo 的结果的“需要”来自 foo 的调用者,而不是 foo 本身。但是由于这种需求始终存在,GHC 可以实现 foo 以返回评估结果。
【解决方案4】:

不一定。 Haskell 是惰性的,这意味着它只在需要时进行评估。这有一些有趣的效果。如果我们以下面的代码为例:

-- File: lazinessTest.hs
(>?) :: a -> b -> b
a >? b = b

main = (putStrLn "Something") >? (putStrLn "Something else")

这是程序的输出:

$ ./lazinessTest
Something else

这表示从未评估过putStrLn "Something"。但它仍然以 'thunk' 的形式传递给函数。这些“thunk”是未评估的值,而不是具体的值,就像如何计算值的面包屑轨迹。这就是 Haskell 惰性的工作原理。

在我们的例子中,两个“thunk”被传递给>?,但只有一个被传递出去,这意味着最后只有一个被评估。这也适用于const,其中第二个参数可以安全地忽略,因此从不计算。至于map,GHC 足够聪明,可以意识到我们并不关心数组的末尾,而只是费心计算它需要的内容,在你的情况下是原始列表的第二个元素。

但是,最好将懒惰的想法留给编译器并继续编码,除非你正在处理 IO,在这种情况下,你真的,真的应该考虑懒惰,因为你很容易出错,就像我一样刚刚演示过。

Haskell wiki 上有 lotslots 的在线文章可供查看,如果您想了解更多详细信息。

【讨论】:

  • 这里看输出并不是一个好的指标。即使对 putStrLn 的第一次调用进行了评估,也不会导致任何输出。
  • 扩展 kosmikus 的评论,评估 thunk 和执行 IO 操作之间存在差异。你所看到的只是第一个 putStrLn 没有被执行(因为你写了一个根本不执行它的程序,这不是很有趣);但就这个实验而言,它很可能在被丢弃而不被执行之前已经被评估过。
【解决方案5】:

函数可以评估任一返回类型:

head (x:_) = x

或异常/错误:

head _ = error "Head: List is empty!"

或底部(⊥)

a = a
b = last [1 ..]

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-10-01
    • 1970-01-01
    相关资源
    最近更新 更多