严格性以及如何告诉 GHC/GHCi 将值一劳永逸地存储在变量中

Syn*_*sus 2 haskell lazy-evaluation ghc ghci strictness

抱歉,如果这是一个常见问题,我找不到类似的问题,但我可能太缺乏经验,不知道正确的词汇。

以下是 GHCi 中基本问题的示例:

-- foo is something relatively expensive to compute, but it's lazy so it's instantaneous
*Main> foo = (foldl (++) [] (take 5000 $ repeat [10, 123, 323, 33, 11, 345, 23, 33, 23, 11, 987]))
(0.00 secs, 62,800 bytes)
-- bar is a result that uses foo, but it's also lazy so it's not computed yet
*Main> bar = last foo
(0.00 secs, 62,800 bytes)
-- Now let's use bar
*Main> bar
987
(1.82 secs, 11,343,660,560 bytes)
-- It took a couple seconds to compute everything, but that's fine. Now let's use it again:
*Main> bar
987
(1.88 secs, 11,343,660,560 bytes)
-- It took 1.88 seconds to presumably recompute everything whereas it
-- could have been instantaneous if GHCi had remembered the value.
Run Code Online (Sandbox Code Playgroud)

我研究了严格性,但我所做的一切似乎都无济于事。据我了解,严格评估一个参数应该会导致所有计算立即完成,但似乎也没有seq启用$!这种行为。例如:

-- expectation: Perhaps evaluating bar will cause 'last foo' to be run systematically,
-- but foo should be evaluated strictly, which should take most of the computation time 
*Main> bar = last $! foo
(0.00 secs, 62,800 bytes)
-- it's actually instantaneous. Sure enough, bar takes time to compute.
*Main> bar
987
(1.88 secs, 11,343,660,632 bytes)
-- what if we use seq? As I understand it, it returns its second argument
-- with the constraint that its first argument must be strict. We want
-- 'bar = last foo' with foo being strict, therefore:
*Main> bar = foo `seq` (last foo)
(0.00 secs, 62,800 bytes)
*Main> bar
987
(1.93 secs, 11,344,585,344 bytes)
-- No dice. What if we evaluate bar strictly and assign that value to a variable 'baz'?
*Main> bar = last foo
(0.00 secs, 62,800 bytes)
*Main> baz = bar `seq` bar
(0.00 secs, 62,800 bytes)
*Main> baz
987
(3.80 secs, 22,689,057,424 bytes)
-- Now it's computing it twice! Once for the `seq` and then a second time to display the value?
-- What if we use BangPatterns? Let's just put a variable through a dummy
-- function that returns its own input (hypothetically) after evaluation.
*Main> eval !x = x
(0.00 secs, 62,800 bytes)
*Main> baz = eval bar
(0.00 secs, 62,800 bytes)
*Main> baz
987
(1.81 secs, 11,345,102,464 bytes)
-- still not doing the computation ahead of time.
Run Code Online (Sandbox Code Playgroud)

所以看起来我根本不明白什么是“严格性”,而且我仍然无法让 GHCi 存储除了 thunk 到变量之外的任何内容。这只是 GHCi 的一个怪癖,还是适用于 GHC?如何让我的代码只存储普通值,就像987在变量中一样?

Ben*_*Ben 8

不幸的是,这里发生了一些不同的事情。

\n

类型类多态性

\n

Joseph Sible 的回答指出了这一点,但我认为还有一些细节值得讨论。如果您输入x = 1 + 1GHCi,x将显示类型Num a => a;它是多态的,并且类型变量对其有约束。在运行时,这些值被表示为一个函数,其参数基本上是Num您要使用的类型的实例(运行时实例称为“字典”)。

\n

这是必要的,因为您可以键入print (x :: Integer),GHCi 必须弄清楚1 + 1该类型的值是什么Integer,或者print (x :: Complex Double)编译器必须弄清楚该类型的值1 + 1是什么Complex Double;这两种类型都使用不同的定义+(以及fromInteger表示整数文字含义的函数)。您甚至可以在定义创建一个全新的类型x,给它一个Num实例,然后 GHCi仍然必须能够print (x :: MyNewNumType)。所有这些基本上意味着在定义它时x :: Num a => a 不能一劳永逸地对其进行评估;相反,它被存储为一个函数,每次您将它用作某种特定类型时,它都会再次求值,以便它的定义可以使用适合此用法的类型的+和函数。fromInteger

\n

在“正常”Haskell 中,这通常不是一个大问题,它是用模块编写的,而不是输入到 GHCi 提示符中的。在正常的 Haskell 中,编译器将能够分析整个模块以查看使用的位置x因此它通常能够推断出特定类型x(即,如果在模块中的其他地方使用x运算符对列表进行索引!!,只接受Int作为索引,因此x将被推断为 type Int,而不是更通用的Num a => a)。如果由于模块中的任何使用站点都不需要x是特定类型而失败,那么还有单态性限制,x无论如何都会强制分配一个具体类型,调用默认规则,Integer在这种情况下会选择该规则。因此,通常定义只会以类型结束,就像Num a => a它明确具有请求该类型的类型签名一样。(单态限制适用于没有任何函数参数的裸变量的绑定,因此它将适用于x = 1 + 1, and f = \\x -> x + 1,但不适用于f x = x + 1

\n

但单态限制在 GHCi 中被禁用。事实上,它是专门添加到语言中的,以避免由于类型类多态值在每次使用时重新计算而不是缓存而导致的性能问题。但只有当应用于整个模块时,它才会产生合理的结果,编译器可以看到变量的用法来分配正确的单态类型。在 GHCi 中,您一次一行地输入内容,迫使编译器为绑定选择一种单态类型,就像x在只看到定义之后效果极差;它经常会为您想要的用途分配错误的类型,然后在您尝试使用它时生成虚假的类型错误。因此,最终在 GHCi 中默认禁用了单态限制,使其更加可用,但也存在不必要的重新计算值的性能问题。

\n

所以这个问题对你来说主要是个问题,因为你在 GHCi 中工作。在“普通”Haskell 中,它仍然可能发生,但默认设置和良好实践(例如为所有顶级绑定提供类型签名)使其在实践中成为一个非常小的问题。

\n

哈斯克尔是永恒的

\n

当第一次学习在 Haskell 中应用严格性时,这是一个极其常见的错误,所以你有很好的伙伴!我将seq在这里讨论,但相同的概念也适用于诸如$!和 bang 模式之类的东西;例如f !x = (x, x)基本上只是 的一个方便的简写f x = x `seq` (x, x)

\n

baz = bar `seq` bar没有做你认为它正在做的事情。事实上它根本没有做任何事情,它相当于简单的baz = bar.

\n

原因是现在seq不会引起求值,原因不是一些奇怪的技术问题,而是Haskell 中没有“现在”这样的东西。这是纯声明性语言和命令式语言之间的根本区别。命令式代码是按顺序执行的一系列步骤,因此时间概念是该语言固有的;有一个“现在”可以“移动”代码。纯声明性代码不是一系列步骤,而是一组定义。没有“现在”,因为没有“时间”;一切都是如此。是对现状的定义,而不是执行的步骤;没有“之前”和“之后”来定义“现在”何时可能导致评估。baz = bar `seq` barbazseqbar

\n

摆脱代码本质上与时间概念联系在一起的概念是程序员第一次接触像 Haskell 这样的纯语言时需要做出的最棘手的心理飞跃之一(并且在一种纯粹但严格的语言中,你不需要这样做)甚至不一定需要;在心理上将此类代码建模为一系列步骤仍然相当容易,即使我认为它仍然不是最好的概念模型)。GHCi 使情况变得复杂,因为解释器显然时间概念;您一次输入一个绑定,改变解释器所知道的一组定义。在正常的 Haskell 模块中,这组定义是静态的,但在 GHCi 中它随着时间的推移而变化,本质上定义了“现在”。But是Haskellseq的一个特性,而不是 GHCi 的一个特性,所以它必须在没有 GHCi 的时间概念的情况下才有意义。即使 GHCi 会话中的定义集随着时间的推移而变化,这些定义本身仍然没有任何时间概念;它们是用 Haskell 编写的,这是永恒的。

\n

因此,如果没有任何时间概念,seq就无法实际控制何时评估某些内容。相反,它所做的是控制评估的内容。Haskell 中的评估是由需求驱动的;如果x正在被评估并且x由 定义x = a + b,那么 的评估x需要评估ab(并且+;从技术上讲,+是立即需要的,并且 的定义+将决定是否需要a和/或,但对于大多数类型来说,两者的定义最终将需要)。所做的只是添加额外的“需要”。在,中被定义为等于(因为这就是返回值),因此评估显然需要评估; 它的特别之处在于它说评估需要评估(并且它可以在不了解or 的情况下执行此操作)。但如果我们从不需要评估自身,那么定义中使用的事实就不会做任何事情。b+seqx = a `seq` bxbseqxbseqxaabxseqx

\n

在 GHCi 的时间概念中,输入绑定时x = a `seq` b不会评估“现在”。a它将评估a是否x曾经使用过(以需要评估它的方式),这将在稍后在命令提示符下输入之后进行。

\n

这就是我们如何等同baz = bar `seq` barbaz = bar. baz = bar `seq` bar意味着当baz被评估时,我们也需要评估bar。但baz等于,所以我们当然需要根据定义进行评估barbar

\n

同样bar = foo `seq` (last foo)没有帮助,因为评估bar需要评估last foo,无论如何都必须评估foo

\n

所以这也是一个问题,在 GHCi 中比在普通 Haskell 中更严重。在 GHCi 中,很容易考虑“现在”,即您将内容输入到命令提示符中,因此您头脑中清楚地了解“现在评估”意味着什么,并期望增加严格性会那样做的。在普通的 Haskell 中,初学者仍然很容易犯这个错误,但不太清楚“现在”的概念没有多大意义,因此希望更容易对能够发生的事情形成更好的期望seq。做。

\n

但当我们有时间的时候我们可以利用它

\n

不过,您可以使用一个快速技巧来利用 GHCi 的时间概念。

\n

如果我b = not True进入 GHCi(一个避免数字避免类型类多态性复杂性的示例),则已b定义,但尚未评估。的定义中使用严格性(通过 via seq$!或其他任何方式)不会有帮助,因为它只能使额外的东西在评估时被评估,而不能改变仅仅定义不会评估触发的基本现实任何额外的严格性。bbbb

\n

但是在定义之后b,我可以立即输入一个命令来进行b评估。在这种情况下,一个简单的方法b就可以很好地工作,因为 GHCi 打印它本质上会强制进行评估。但如果我不想实际打印b,我可以输入:

\n
b `seq` ()\n
Run Code Online (Sandbox Code Playgroud)\n

这告诉 GHCi 打印(),但要做到这一点,它首先必须进行评估b(而不用它做任何其他事情)。

\n

另一件可以给我们时间概念的东西是IOIO代码仍然是 Haskell 代码,这意味着它在技术上是“永恒的”。但其界面IO旨在建模(在永恒的 Haskell 中),可以通过与现实世界交互来完成的事情。现实世界绝对不是IO永恒的,因此时间概念本质上进入了代码的范畴;抽象 monad 的数据依赖性有效地从 Haskell 的永恒语义中构建了一个时间概念,并且将IO执行与现实世界挂钩,以便构建的时间与现实世界时间相匹配。这意味着表示“立即评估”的函数在 中确实有意义IO,并且确实存在该函数evaluate :: a -> IO a(但您必须从中导入它Control.Exception)。这是使用IO时间概念而不是 GHCi 的时间概念,因此这甚至可以在 Haskell 模块中使用!但它当然只适用于IO代码。

\n

因此,evaluate bar在 GHCi 中用作命令可以作为评估bar“现在”的一种方式。(evaluate虽然返回值,所以 GHCi 会打印它;如果你不希望这样,那么bar `seq` ()仍然更好)

\n

您甚至可以将其合并evaluate到 GHCi 中变量的定义中,以使其按照定义进行评估,但是您必须使用单子绑定<-而不是正常定义=(利用 GHCi 提示符或多或少“内部”的事实)IO”)。

\n
\xce\xbb b = not True\nb :: Bool\n\n\xce\xbb :sprint b\nb = _\n\n\xce\xbb b\nFalse\nit :: Bool\n\n\xce\xbb :sprint b\nb = False\n\n\xce\xbb c <- evaluate $ not True\nc :: Bool\n\n\xce\xbb :sprint c\nc = False\n
Run Code Online (Sandbox Code Playgroud)\n

(请注意,这里我使用的是:sprintGHCi 特殊命令;而不是使用 将整个值转换为字符串show:print:sprint打印出旨在显示值的结构而不强制进行任何计算的字符串;:sprint仅显示_在何处找到未计算的值,同时:print还包括有关其类型的信息。这些对于检查其他命令是否引起评估非常方便;它比依赖处理命令所需的时间更精确。您必须在但变量,不是表达式)

\n

类似地,其他一些单子(如State等)也可以被视为在 Haskell 的永恒语义之上构建时间概念,但它们不允许像确实那样出现任意的副作用IO,并且它们构建的时间线不一定匹配跟上现实世界的时间,因为每当永恒的惰性 Haskell 需要时就会对它们进行评估(因此可能运行零次或多次);它们更多的是对时间的模拟。因此,从这些时间线派生的“现在”中的“立即评估”功能不会那么有用。

\n

弱头正常形态

\n

最后,试图严格评估仍然存在问题foo。假设您使用类型注释来避免多态性,并使用额外的foo `seq` ()命令来评估它,如下所示:

\n
\xce\xbb foo = (foldl (++) [] (take 5000 $ repeat [10, 123, 323, 33, 11, 345, 23, 33, 23, 11, 987 :: Integer]))\nfoo :: [Integer]\n\n\xce\xbb foo `seq` ()\n()\nit :: ()\n
Run Code Online (Sandbox Code Playgroud)\n

这实际上并没有评估“全部” foo

\n
\xce\xbb :sprint foo\nfoo = 10 : _\n
Run Code Online (Sandbox Code Playgroud)\n

我们只评估了列表的第一个元素!这可能不是您想要的。

\n

到目前为止,在这篇文章中,我们只是讨论了正在评估的值,没有任何说明,就像单个原子步骤一样。Bool对于非常简单的值(如s、Ints、 s 等)来说确实如此。Char但大多数值(如列表)具有比这更多的结构,可能包含许多可以计算或不可计算的子值。因此,当我们说正在评估这样一个值时,我们的意思有很多种可能性。

\n

在 Haskell 中,评估的“标准”概念始终是弱头范式。它有一个奇特的技术定义,但基本上它意味着评估,直到我们拥有最外层的数据构造函数(如 for:列表)。这是因为Haskell的需求驱动评估中的“需求”大部分来自于模式匹配;至少,系统需要知道最外层的数据构造函数是什么,以决定在模式匹配中采用哪个分支(例如,许多列表函数将有一个 case for[]和一个 case for :)。最外层构造函数中包含的所有值(例如列表的头部和尾部)如果还没有被其他东西评估过,则将不被评估,直到更多的模式匹配需要检查这些包含的值。

\n

因此,当我们说foo `seq` ()等于 a()但还需要评估时foo,评估()唯一导致foo评估的第一个列表单元格(它甚至不评估该列表单元格的值,但因为它最终来自源代码中的文字,它已经被评估,这就是为什么:sprint显示10 : _而不是_ : _)。

\n

深度测序

\n

该模块Control.DeepSeq具有强制进行深度评估的工具。例如, 可以以deepseq与 相同的方式使用seq,但它强制对左侧参数进行完全评估。还有force可以与 结合使用evaluate来强制“现在”进行深度评估(使用IO\ 的“现在”概念)。

\n

不过,这一切都基于类型类NFData(名称中的 NF 是“规范形式”的缩写;规范形式被完全评估,而弱头规范形式被评估为最外面的构造函数)。您不能deepseq使用未实现该类的类型的值。大多数标准类型已经有实例,因此这对于foo :: [Integer]. 但如果您使用自己的类型,则需要为它们提供NFData.

\n

因此,这将实际定义foo并完全评估它(不打印它):

\n
\xce\xbb foo = (foldl\' (++) [] (take 5000 $ repeat [10, 123, 323, 33, 11, 345, 23, 33, 23, 11, 987 :: Integer]))\nfoo :: [Integer]\n\n\xce\xbb foo `deepseq` ()\n()\nit :: ()\n
Run Code Online (Sandbox Code Playgroud)\n

但深度评估本质上涉及对数据的完整遍历(如果不查看所有内容,就无法评估所有内容)。deepseq因此,大量使用可能会非常低效;如果您打算稍后对其进行评估,那么您就是在浪费时间对数据进行额外的传递(如果您稍后不打算使用它,那么您将迫使系统花时间运行代码,它可能会完全跳过)。

\n

当你的计算依赖于大型结构时,它会很有用,而该计算只会产生一个小结构(但大于一个数字,否则seq就足以强制它);如果它会在很长一段时间内仅部分评估,deepseq则它可能根本不需要将大型结构保留在内存中,这可能会更有效(即使您已经在小结果上添加了额外的小传递)结构)。

\n

unsafePerformIO尽管当计算可能抛出异常(或者有副作用等)时,它可能更常用;deepseq它将强制这些异常或副作用在已知点显现,而不是在以后的代码碰巧需要结果的特定部分时出现。这就是组合特别有用的地方,因为你在有明确定义的“现在”的情况evaluate . force下强制使用它,并且还可以处理异常。IO

\n

但如果可以的话,通常 deepseq最好避免。它是一个可供备用使用的重要工具,而不是使 Haskell 的行为与严格语言完全相同的方法。

\n


Jos*_*ica 6

问题在于foobar是多态的。在运行时,=>基本上变成->并隐式传递类型类的字典。这可以防止保存您想要的结果,因为它只会保存函数而不是其结果。如果您将类型限制为[Int]/Int而不是Num a => [a]/ Num a => a,那么它们只会被评估一次,而不是每次。

至于为什么你试图让它提前评估是行不通的,那是因为无论你在定义某事物时使用了多少seqs 或s,你所能用它们完成的就是“让那个东西在这个东西之前评估”!确实”,而不是“立即评估该事物”。