理解Haskell中的评估顺序

时间:2018-01-22 14:41:14

标签: haskell

我试图了解Haskell中where子句的评估方式。假设我们得到了这个玩具示例,其中bar,baz和bat在某处定义了函数:

func x = foo i j k
  where
    foo i j k = i + j + k
    k = bat x
    j = baz k
    i = bar j

func x = foo i j k行是如何扩展的?是评估为func x = foo(i(j(k)), j(k), k)还是func x = foo(i, j, k)

4 个答案:

答案 0 :(得分:8)

简介

我假设你打算写这段代码:

func :: Int -> Int
func x = foo
  where
    foo = i + j + k
    k = bat x
    j = baz k
    i = bar j

这样它将进行类型检查,并且最终将调用您在where子句中定义的所有三个函数。如果这不是你的意思,仍然继续阅读,因为我不仅会给你一个代码评估方式的描述,而且还会给你一个自己确定答案的方法。这可能是一个长篇故事,但我希望它值得你花时间。

核心

代码的评估绝对取决于您对编译器的选择,但我想您将使用GHC,如果是这样,它会将代码转换几次,然后再将其转换为机器代码。

首先," where条款" 将替换为" let条款" 。这样做是为了将Haskell语法简化为更简单的 Core 语法。核心与称为 lambda calculus 的数学理论相似,因为它最终的评估是根据这个坚实的基础进行的。此时您的代码看起来会像这样:

func = λx ->
      let { k = bat x } in
      let { j = baz k } in
      +
        (+ (bar j) j)
        k

如您所见,Haskell代码的where子句中的一个函数定义在到达Core阶段时实际上消失了(实际上,它被内联),其他的被重写为{ {1}}表示法。二进制操作(+)被重写为抛光符号以使其明确(现在很清楚应该首先计算let)。所有这些转换都是在不改变代码含义的情况下执行的。

图形机

然后,生成的 lambda表达式将缩减为有向图并由 Spineless无标记图形机执行。从某种意义上说,Core to STG机器是汇编程序对图灵机的作用,虽然前者是lambda表达式,而后者是一系列命令式指令。 (正如您现在所看到的,功能语言和命令式语言之间的区别运行得相当深。)STG机器会将您提供的表达式转换为传统计算机上 executable 的命令性指令,通过严格定义的操作语义 - 也就是说,对于Core的每个语法特性(其中只有大约4个),有一条命令式汇编程序指令执行相同的操作,而一个核心程序将被翻译成这些作品的组合。

Core的操作语义的关键特性是 laziness 。如你所知,Haskell是一种懒惰的语言。这意味着要计算的函数和该函数的值看起来是一样的:作为RAM中的字节序列。当程序启动时,所有内容都被设置为函数(确切地说是闭包),但是一旦函数的返回值被计算出来,它就会被放在闭包的位置所以所有进一步访问内存中的此位置将立即收到该值。换句话说,只有在需要时才计算值,并且只有一次

正如我所说,Core中的表达式将转向相互依赖的有向计算图。例如:

enter image description here

如果你仔细观察,我希望这个图表会提醒你我们开始的程序。请注意两个细节:

  • 所有箭头最终都会显示i + j,这符合我们的观点,即提供x足以评估x

  • 有时两个箭头指向同一个框。这意味着这个盒子的价值将被评估一次,第二次我们将获得免费的价值。

因此,STG机器将采用一些核心代码并创建一个可执行文件,用于计算与图片中的图形或多或少类似的图形值。

执行

现在,当我们进入图表时,很容易看出计算将会如此进行:

  1. 调用func时,会收到func的值并将其放入相应的框中。
  2. 计算
  3. x并将其放在一个框中。
  4. bat x设置为与k相同。 (GHC在代码上运行的一些优化可能会删除此步骤,但实际上bat x子句要求将其值单独存储。)
  5. 计算
  6. let并将其放在一个框中。
  7. baz k设置为与j相同,与步骤6中的baz k相同。k计算并放在一个框中。
  8. 与根据步骤3和5所期望的相反,bar j没有任何设定。正如我们在Core的列表中看到的那样,它已被优化了。
  9. 计算
  10. i+ (bar j) j已经计算好了,因此,由于懒惰,j
  11. 计算最高值。同样,这次不需要计算baz k,因为它是先前计算并存储在右侧框中。
  12. 现在,bat x的值本身就是一个盒子,准备好被调用者多次使用。
  13. 我要强调的是,这是执行程序时将发生的事情,而不是编译它。

    后记

    据我所知,这就是故事。为了进一步澄清,我向读者推荐Simon Peyton Jones的作品:the book on the design of Haskellthe article on the design of Graph machine,共同描述了GHC对最小特性的所有内部运作。

    要查看GHC生成的Core,只需在编译时传递标记func x。一开始会伤到你的眼睛,但人们会习惯它。

    享受!

    postscriptum

    正如@DanielWagner在评论中指出的那样,Haskell的懒惰有一些进一步的后果,我们应该考虑解决一个不那么做作的案例。具体来说:计算可能不需要来评估它指向的某些方框,甚至根本不是任何方框。在这种情况下,这些框将保持不变和未评估,同时计算完成并且无论如何都实现其实际上独立于从属框的结果。这种功能的一个例子:-ddump-simpl。这会产生深远的影响:例如,如果f x = 3无法计算,就像在"无限循环"中一样,不使用 x的函数首先,根本不会进入那个循环。因此,有时希望详细地知道哪些子计算必须从给定的计算中启动而哪些子计算可能不会。这种错综复杂的情况比我准备在这个答案中描述的要稍微远一点,所以在这个谨慎的说明中我会结束。

答案 1 :(得分:5)

未指定评估顺序(在Haskell报告中)以进行添加。因此,评估顺序取决于您的号码类型及其Num个实例。

例如,下面是两种具有Num实例和反向评估顺序的类型。我使用了自定义的Show实例并调试打印输出,以便在输出中更容易看到。

import Debug.Trace

newtype LeftFirst = LF { unLF :: Integer }
instance Show LeftFirst where show (LF x) = x `seq` "LF"++show x
newtype RightFirst = RF { unRF :: Integer }
instance Show RightFirst where show (RF x) = x `seq` "RF"++show x

instance Num LeftFirst where
    (+) a b = a `seq` LF (unLF a + unLF b)
    fromInteger x = trace ("LF" ++ show x) (LF x)

instance Num RightFirst where
    (+) a b = b `seq` RF (unRF a + unRF b)
    fromInteger x = trace ("RF" ++ show x) (RF x)

func :: Num a => a -> a
func x = foo i j k
  where
    foo i j k = i + j + k
    k = bat x
    j = baz k
    i = bar j

bar,baz,bat :: Num a => a -> a
bar = (+1)
baz = (+2)
bat = (+3)

注意输出:

*Main> func (0 :: LeftFirst)
LF0
LF3
LF2
LF1
LF14
*Main> func (0 :: RightFirst)
RF3
RF0
RF2
RF1
RF14

答案 2 :(得分:3)

首先,foo i j k将解析为((foo i) j) k。这是因为Haskell中的所有函数都只使用一个参数。 foo的一个arg是i,结果(foo i)是一个arg为j等的函数。所以,它既不是foo(i(j(k)))也不是{ {1}};但是,我应该警告你,foo (i, j, k)最终会在某种意义上等同于((foo i) j) k,因为我们可以根据你的意愿进行调查。

其次,foo (i, j, k)ij不会作为缩减值传递给k,而是作为表达式,并且最多foo决定(通过foo的公式)如何以及何时评估每个提供的表达式。在foo的情况下,我很确定它只是从左到右。因此,(+)将被强制优先,但当然要评估i,所有其他的都需要进行评估,因此您需要将数据依赖树追溯到其叶子,这些叶子在{{ 1}}。

这里的微妙之处可能是“减少”和“完全减少”之间存在区别。 i将首先被删除,因为一层抽象 - 名称x - 被删除并替换为i的公式,但它并没有完全减少要完全减少i,我们需要完全减少它的数据依赖性。

答案 3 :(得分:2)

如果我理解你的问题(以及后续评论),我猜你并不真正对评估的顺序感兴趣"或者特定Haskell编译器如何实际执行评估的详细信息。相反,您只是对了解以下程序意味着什么(即其"语义")感兴趣:

func x = foo i j k
  where
    foo i j k = i + j + k
    k = bat x
    j = baz k
    i = bar j

这样您就可以预测func 10的价值。正确?

如果是这样,那么您需要了解的是:

  • 如何确定名称范围(例如,以便您了解x定义中的k引用x定义中的参数func x等等上)
  • "参考透明度"的概念,它基本上是Haskell程序的属性,变量可以用它的定义替换,而不会影响程序的含义。

关于涉及where子句的变量作用域,了解where子句附加到特定的"绑定"是有用的。 - 这里,where子句附加到func x的绑定。 where子句同时执行三项操作:

首先,它将相关绑定(此处为func)中定义的事物的名称和任何参数的名称(此处为x)纳入其自己的范围。 func子句中对xwhere的任何引用都将引用func绑定中的xfunc x正在定义(假设where子句不本身funcx定义新约束" shadow"那些绑定 - 这不是问题)。在您的示例中,含义是定义x中的k = bat x引用x绑定中的参数func x

其次,它在自己的范围内引入了where子句定义的所有内容的名称(此处为fookj和{ {1}}),虽然不是参数。也就是说,绑定i中的ijk NOT 引入范围,如果您编译程序foo i j k标志,您将收到有关阴影绑定的警告。因此,您的程序实际上相当于:

-Wall

我们将在下面使用此版本。上述含义是func x = foo i j k where foo i' j' k' = i' + j' + k' k = bat x j = baz k i = bar j 中的k是指j = baz k定义的k,而k = bat x中的j是指由i = bar j定义的jj = baz k子句定义的ijk与{无关绑定where中的{1}},i'j'参数。另请注意,绑定的顺序并不重要。您可以写下:

k'

它意味着完全一样的东西。即使在foo i' j' k'的绑定被定义之前定义func x = foo i j k where foo i' j' k' = i' + j' + k' i = bar j j = baz k k = bat x ,也没有区别 - 它仍然是i = bar j

第三,j子句在相关绑定的右侧范围内引入了前一段中讨论的名称。对于您的示例,名称jwherefook被引入相关绑定{{1的右侧的表达式范围内}}。 (同样,如果涉及任何阴影,则会有一个微妙之处 - j子句中的绑定将覆盖引入ifunc x = foo i j k的绑定在左侧,如果使用where编译,也会生成警告。幸运的是,您的示例没有出现此问题。)

所有这些范围的结果是,在程序中:

func

每个名称的每个用法都指同一个东西(例如,所有x名称都指的是同一个东西)。

现在,引用透明度规则发挥作用。您可以通过用其定义替换任何名称来确定表达式的含义(注意避免名称冲突或所谓的"捕获"名称)。因此,如果我们正在评估-Wall,那么它将等同于:

func x = foo i j k
  where
    foo i' j' k' = i' + j' + k'
    k = bat x
    j = baz k
    i = bar j

在此阶段,使用的k定义将func 10绑定到func 10 -- binds x to 10 = foo i j k -- by defn of func foo绑定到i'i绑定到j' j以生成表达式:

k'

所以,如果我们定义:

k

然后我们期待:

= i + j + k                                    -- by defn of foo
= bar j + baz k + bat x                        -- by defs of i, j, k
= bar (baz k) + baz k + bat x                  -- by defn of j
= bar (baz (bat x)) + baz (bat x) + bat x      -- by defn of k
= bar (baz (bat 10)) + baz (bat 10) + bat 10   -- by defn of x

这正是我们得到的:

bat = negate
baz y = 7 + y
bar z = 2*z