Haskell 递归和内存使用

Posted

技术标签:

【中文标题】Haskell 递归和内存使用【英文标题】:Haskell recursion and memory usage 【发布时间】:2012-11-26 18:15:40 【问题描述】:

我对用递归替换循环的想法感到满意。我正在摆弄一个宠物项目,我想测试一些文本输入功能,所以我写了一个小命令行界面,它反复询问输入,直到它收到特定的退出命令。

看起来像这样:

getCommandsFromUser = do
    putStrLn "Enter command: "
    keyboardInput <- getLine
    let command = map toLower keyboardInput
    if command == "quit"
        then 
            putStrLn "Good-bye!"
        else do
            -- stuff
            -- more stuff
            putStrLn ("Sending command: " ++ commandURI)
            simpleHTTP $ getRequest commandURI
            getCommandsFromUser

main = do
    getCommandsFromUser

这完全符合预期,但是来自 C/Java 背景,它仍然让我大脑深处、黑暗、无意识的部分发痒,让我想在荨麻疹中爆发,因为我无法摆脱这样的想法,即每一个对 getCommandsFromUser 的递归调用正在创建一个新的堆栈帧。

现在,我对 IO、monad、状态、箭头等一无所知。我仍在通过 Real World Haskell 工作,我还没有达到那部分,并且其中一些代码与我在 Google 上找到的东西的模式匹配。

此外,我知道 GHC 的全部意义在于,它是一个令人发狂的优化编译器,旨在完成令人难以置信的事情,例如漂亮地展开尾递归函数等。

那么有人可以解释一下这个实现是否“正确”,如果是的话,请向我解释一下幕后发生的事情,如果这个程序被无限数量的人掌握,它会阻止它崩溃。猴子?

我知道尾调用优化是什么。我更关心它在这种情况下是如何工作的,以及正在发生的动作和一般功能杂质。

这个问题并不是因为我对 Haskell 如何使用堆栈感到困惑,并且我期望它像命令式语言一样工作;它基于这样一个事实,即我不知道 Haskell 是如何处理堆栈的,并且想知道它与传统的类 C 语言有何不同。

【问题讨论】:

我建议您在 SO 或一般网络上查找“尾调用优化”。这是一个通用的编译器/语言概念,不仅由 Haskell 编译器实现,也由 GCC 等实现。 @ThomasM.DuBuisson 从对 TCO 的严格解释来看,实际上并不明显它适用于 (&gt;&gt;=) 右侧的事物。事实上,对于许多单子来说,它不是。非常小心以确保它确实适用于 IO。 @ThomasM.DuBuisson 如原始问题所述,我熟悉尾调用优化及其工作原理。我在 Haskell 的情况下询问它,特别是它在这种(单子的,不纯的)情况下的使用。 “尾调用优化”并不是一个真正有意义的短语,当您谈论评估像 Haskell 这样的非严格语言时。评估以完全不同的方式进行; “TCO”是您默认获得的,根本不是真正的“优化”。 【参考方案1】:

不要太担心堆栈。没有什么基本的东西说函数调用必须使用堆栈帧来实现。这只是实现它们的一种可能技术。

即使您拥有“堆栈”,也没有任何内容表明堆栈必须限制为可用内存的一小部分。这本质上是一种针对命令式编程的启发式方法。如果您不使用递归作为解决问题的技术,那么非常深的调用堆栈往往是由无限递归错误导致的,并且将堆栈大小限制在非常小的范围内意味着此类程序会很快死亡,而不是消耗所有可用内存和交换然后死了。

对于函数式程序员来说,当计算机仍然有千兆字节的 RAM 可用时,让程序终止“用尽”内存以进行更多函数调用是语言设计中的一个荒谬缺陷。这就像 C 将循环限制为任意数量的迭代。因此,即使函数式语言通过使用堆栈来实现函数调用,如果可能的话,也会有强烈的动机避免使用我们从 C 中知道的标准微型堆栈。

事实上,Haskell 确实有 a 堆栈可以溢出,但它不是你熟悉的 C 中的调用堆栈。很有可能编写无限的非尾递归函数递归并将消耗所有可用内存而不会达到调用深度的限制。 Haskell 确实拥有的堆栈用于跟踪需要更多评估以做出决定的“待定”值(稍后我将详细介绍)。你可以阅读更多关于这种堆栈溢出的详细信息here。

让我们通过一个示例来了解如何评估您的代码。1不过,我将使用一个比您的更简单的示例:

main = do
    input <- getLine
    if input == "quit"
        then
            putStrLn "Good-bye!"
        else do
            putStrLn $ "You typed: " ++ input
            main

Haskell 的评估是惰性的2。简单地说,这意味着它只会在需要该术语的值来做出决定时才费心评估该术语。例如,如果我计算1 + 1,然后将其结果添加到列表的前面,它可以在列表3 中保留为“待处理”1 + 1。但是如果我使用if 来测试结果是否等于3,那么 Haskell 将需要实际完成将1 + 1 转换为2 的工作。

但如果仅此而已,就不会发生任何事情。整个程序将仅保留为“待定”值。但是有一个外部驱动程序需要知道 main 评估的 IO 操作是什么,才能执行它。

回到例子。 main 等于 do 块。对于IOdo 块从一系列较小的动作中生成一个大的IO 动作,这些动作必须按顺序执行。因此,Haskell 运行时看到main 评估为input &lt;- getLine,然后是一些它还不需要的未评估的东西。这足以知道从键盘读取并调用生成的Stringinput。假设我输入了“foo”。这使得 Haskell 的“下一个”IO 操作如下所示:

if "foo" == "quit"
    then
        putStrLn "Good-bye!"
    else do
        putStrLn $ "You typed: " ++ "foo"
        main

Haskell 只关注最外面的东西,所以这看起来很像“if blah blah blah blah ...”。 if 不是 IO-executor 可以做的任何事情,因此需要对其进行评估以查看它返回的内容。 if 只计算 thenelse 分支,但要知道评估条件需要做出 Haskell 的哪个决定。所以我们得到:

if False
    then
        putStrLn "Good-bye!"
    else do
        putStrLn $ "You typed: " ++ "foo"
        main

这允许将整个if 简化为:

do
    putStrLn $ "You typed: " ++ "foo"
    main

再一次,do 给了我们一个 IO 动作,它由有序的子动作序列组成。所以接下来IO-executor要做的就是putStrLn $ "You typed: " ++ "foo"。但这也不是IO 操作(它是一个未评估的计算,应该导致一个)。所以我们需要对其进行评估。

putStrLn $ "You typed: " ++ "foo" 的“最外层”部分实际上是$。去掉中缀运算符语法,这样你就可以像 Haskell runtiem 一样看到它,它看起来像这样:

($) putStrLn ((++) "You typed: " "foo")

但是$ 运算符刚刚由($) f x = f x 定义,所以立即替换右侧给我们:

putStrLn ((++) "You typed: " "foo")`

现在我们通常会通过代入putStrLn 的定义来评估它,但它是一个“神奇”的原始函数,不能在 Haskell 代码中直接表达。所以它实际上并没有像这样被评估;外部 IO 执行器只知道如何处理它。但它要求putStrLn参数 被完全评估,所以我们不能将其保留为(++) "You typed: " "foo"

实际上有许多步骤来完全评估该表达式,通过列表操作的++ 的定义,但让我们跳过它并说它评估为"You typed: foo"。那么 IO-executor 可以执行putStrLn(将文本写入控制台),然后继续到do 块的第二部分,即:

`main`

这不是可以作为IO 操作立即执行的东西(它不像putStrLngetLine 那样内置在Haskell 中),所以我们使用定义的右侧来评估它main获取:

do
    input <- getLine
    if input == "quit"
        then
            putStrLn "Good-bye!"
        else do
            putStrLn $ "You typed: " ++ input
            main

而且我相信您可以看到其余部分的进展情况。

请注意,我没有提到任何类型的堆栈。所有这一切只是建立了一个描述IO动作的数据结构,即main,因此外部驱动程序可以执行它。它甚至不是一个特别特殊的数据结构;从评估系统的角度来看,它就像任何其他数据结构一样,因此对其大小没有任意限制。

在这种情况下,惰性求值意味着这个数据结构的生成与它的消耗是交错的(它后面部分的生成可能取决于消耗它前面部分的结果!),所以这个程序可以在恒定的空间中运行。但正如 shachaf 对该问题的评论所指出的,这并不是删除不必要的堆栈帧的真正优化;这正是惰性求值自动发生的事情。


所以我希望这对您了解正在发生的事情有足够的帮助。基本上,当 Haskell 开始评估对 getCommandsFromUser 的递归调用时,它已经完成了前一次迭代中生成的所有数据,因此它会被垃圾收集。所以你可以无限期地运行这个程序,而不需要超过固定数量的内存。这只是惰性求值的直接结果,在涉及IO 时并没有本质上的不同。


1 我要先声明,我对 Haskell 的实际当前实现并不了解太多细节。然而,我确实知道实现像 Haskell 这样的惰性纯语言的一般技术。我还将尽量避免过多地深入细节,并以直观的方式解释事物的工作原理。因此,在您计算机内部实际发生的一些细节上,这个帐户很可能是不正确的,但它应该向您展示这些东西是如何工作的。

2 语言规范在技术上只是说评估应该是“非严格的”。我要描述的评估,非正式地称为“惰性”,实际上只是一种可能的“非严格”评估策略,但它是你在实践中得到的。

3 实际上,新列表可以作为(1 + 1) : originalList 的“待处理”结果保留,直到有人需要知道它是否为空。

【讨论】:

【参考方案2】:

这个实现是正确的。

我不认为尾调用优化真的是使这项工作有效的一点。相反,不管你信不信,让它高效工作的是 IO 操作的不变性。您对 IO 操作是不可变的感到惊讶吗?我一开始是!这意味着:getCommandsFromUser 是“要做的事情”的秘诀;并且每次您评估 getCommandsFromUser 时,它都会评估为相同的配方。 (当然,不是每次你按照食谱做的都会得到相同的结果!但这完全是不同的执行阶段。)

这样做的结果是getCommandsFromUser 的所有评估都可以共享——GHC 只在内存中保留一份配方副本,该配方的一部分包括一个指向配方开头的指针。

【讨论】:

那么分配呢?假设在“do stuff”注释块中有一个“let foo = bar”语句(在我的运行代码中,它基于命令行输入)。这让我回到了关于堆栈帧的原始问题。我想我从概念上理解你在说什么,并不是我不明白存在机制来完成这项工作是可能的;我只想知道这些机制是什么,因为我觉得它非常迷人。为什么我在对 getCommandsFromUser 进行一百万次递归调用后不会溢出堆栈? @DougStephen 好吧,我猜你的部分困惑来自于不了解 Haskell 如何使用堆栈。函数调用不会在 Haskell 中添加堆栈帧;相反,堆栈帧来自嵌套 thunk。看看this question 可能会对您有所帮助,我认为它说明了命令式程序员期望使用堆栈的方式与 GHC 实际使用堆栈的方式之间的区别。 @DanielWagner 这正是我正在寻找的东西。我想我没有很好地表达我的问题。与其说是混乱,不如说是我只是不知道并且正在寻找一个好的解释。既然您已经说过,将堆栈帧绑定到 thunk 就很直观了。非常感谢! 我很确定尾调用与它有关。如果getCommandsFromUser 不是do 表示法中的最后一项,那么IO 评估将需要保留一个堆栈(用于“指向回”)而不是仅指向开头。我不明白这与不变性有什么关系。【参考方案3】:

据我了解,您应该忘记 TCO:与其询问递归调用是否处于尾部位置,不如考虑 受保护的递归。 This answer 我认为是对的。您还可以从总是有趣且具有挑战性的“无限社区”博客中查看Data and Codata 上的帖子。最后看看Space Leak Zoo。

编辑:很抱歉,以上内容没有直接解决您关于单子操作的问题;我有兴趣看到像 DanielWagner 这样专门针对 IO monad 的其他答案。

【讨论】:

【参考方案4】:

涉及到 IO 没关系。你可以在 Haskell wiki 中阅读它:

IO inside

或者,为了更深入地体验 Haskell 的 IO:

Tackling the awkward squad: monadic input/output, concurrency, exceptions, and foreign-language calls in Haskell

【讨论】:

以上是关于Haskell 递归和内存使用的主要内容,如果未能解决你的问题,请参考以下文章

Haskell,分析导入库的内存使用情况

如何在 Haskell 中查找程序的内存使用情况

尽管总内存使用量只有 22Mb,但 Haskell 线程堆溢出?

Haskell 中的内存高效字符串

在 Haskell 中优化基数排序

多线程haskell程序的内存分析