Haskell递归和内存使用

时间:2012-12-08 21:41:38

标签: memory recursion functional-programming haskell

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

它看起来像这样:

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,monads,状态,箭头等一无所知。我还在通过Real World Haskell工作,我还没有达到那个部分,而且这些代码中的一些与我在Google上找到的东西相匹配。

此外,我知道GHC的重点在于它是一个令人抓狂的优化编译器,旨在完成令人难以置信的事情,例如美丽展开尾递归函数等。

所以有人可以解释这个实现是否“正确”,如果是这样的话,向我解释幕后会发生什么事情会阻止这个程序被炸毁,如果它被放在无限数量的手中猴子?

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

这个问题并不是基于我对Haskell如何使用堆栈感到困惑的想法,而是我希望它像命令式语言一样工作;它的基础是我不知道Haskell如何处理堆栈,并想知道它与传统的C语言有什么不同。

4 个答案:

答案 0 :(得分:35)

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

即使你有#34;堆栈&#34;,当然也没有什么说堆栈必须限制在一小部分可用内存中。这本质上是一种启发式调整到命令式编程;在你不使用递归作为解决问题技术的地方,非常深的调用堆栈往往是由无限递归错误引起的,并且将堆栈大小限制为非常小的意味着这样的程序快速死亡而不是消耗所有可用的内存和交换然后就要死了。

对于功能程序员来说,程序终止&#34;用完了#34;当计算机仍然有数GB的RAM可用时,内存可以进行更多的函数调用是语言设计中一个荒谬的缺陷。这就像C限制循环到一些任意数量的迭代。因此,即使函数式语言 通过使用堆栈实现函数调用,也会有强烈的动机避免使用我们从C中知道的标准微小堆栈。

事实上,Haskell确实有一个堆栈,它可以溢出,但它不是你熟悉的C调用堆栈。很有可能写非尾递归函数,无限递归并将消耗所有可用内存,而不会限制调用深度。 Haskell所拥有的堆栈用于跟踪&#34; pending&#34;需要进行更多评估的值才能做出决定(我稍后再讨论这个问题)。您可以更详细地阅读这种堆栈溢出here

让我们通过一个例子来看看你的代码是如何被评估的。 1 我会使用比你更简单的例子:

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

Haskell的评估是懒惰的 2 。简单地说,这意味着当需要该术语的值来做出决定时,它只会费心去评估一个术语。例如,如果我计算1 + 1然后将其结果预先添加到列表的前面,则可以将其保留为&#34; pending&#34;列表 3 中的1 + 1。但是,如果我使用if来测试结果是否等于3,那么那么 Haskell将需要实际将1 + 1转换为2。< / p>

但是,如果那就是它的全部,那么什么都不会发生。整个程序只会留下&#34;待定&#34;值。但是有一个外部驱动程序需要知道IO动作main的评估结果,以便执行它。

回到例子。 main等于do块。对于IOdo块会从一系列较小的IO块中执行大型main操作,必须按顺序执行。因此,Haskell运行时看到input <- getLine评估为String,然后是一些尚未评估的东西,它们还不需要。这足以让您知道从键盘上读取并调用生成的input IO。我输入&#34; foo&#34;。这让Haskell有了类似下面的东西作为&#34; next&#34; if "foo" == "quit" then putStrLn "Good-bye!" else do putStrLn $ "You typed: " ++ "foo" main 行动:

if

Haskell只是看着最外面的东西,所以这看起来很像&#34; 如果等等等等等等等等......&#34;。 if不是IO执行程序可以执行任何操作的东西,因此需要对其进行评估以查看它返回的内容。 then只评估elseif False then putStrLn "Good-bye!" else do putStrLn $ "You typed: " ++ "foo" main 分支,但要知道哪个决策需要Haskell来评估条件。所以我们得到:

if

允许整个do putStrLn $ "You typed: " ++ "foo" main 缩减为:

do

同样,IO为我们提供了putStrLn $ "You typed: " ++ "foo"动作,其中包含一系列有序的子动作。所以IO执行者要做的下一件事就是IO。但这也不是putStrLn $ "You typed: " ++ "foo"行动(它是一个未评估的计算,应该导致一个)。所以我们需要评估它。

&#34;最外面&#34; $的一部分实际上是($) putStrLn ((++) "You typed: " "foo") 。摆脱中缀运算符语法,以便您可以像Haskell runtiem一样查看它,它看起来像这样:

$

但是由($) f x = f x定义的putStrLn ((++) "You typed: " "foo")` 运算符,因此立即替换右侧给我们:

putStrLn

现在通常我们通过替换putStrLn的定义对此进行评估,但它是一个&#34;魔法&#34;在Haskell代码中不能直接表达的原始函数。所以它实际上并没有像这样被评估;外部IO执行程序只知道如何处理它。但它需要对(++) "You typed: " "foo"参数进行全面评估,因此我们不能将其保留为++

实际上有很多步骤可以完全评估该表达式,在列表操作方面完成"You typed: foo"的定义,但是我们可以跳过它并说它评估为{{1} }。那么IO执行程序可以执行putStrLn(将文本写入控制台),然后转到do块的第二部分,这只是:

`main`

哪个不能立即作为IO操作执行(它不是内置于Haskell中的putStrLngetLine),因此我们对其进行评估通过使用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 技术上的语言规范只是说评估应该是非严格的&#34;。我要描述的评价,被称为&#34;懒惰&#34;非正式地,实际上只有一种可能的&#34;非严格的&#34;评估策略,但它是你在实践中得到的。

3 事实上,新名单可以保留为&#34;待定&#34; (1 + 1) : originalList的结果,直到有人需要知道它是否为空。

答案 1 :(得分:8)

此实施是正确的。

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

这样做的结果是所有getCommandsFromUser的评估都可以共享 - GHC只是将一份食谱保存在内存中,而该食谱的一部分包括一个指向食谱开头的指针。 / p>

答案 2 :(得分:3)

据我所知,你应该忘记TCO:而不是询问递归调用是否处于尾部位置,而是考虑保护递归This answer我认为是对的。您也可以查看Data and Codata上有关“无限邻居”博客的有趣且具有挑战性的帖子。最后查看Space Leak Zoo

编辑:对不起,上述内容并未直接解决您关于monadic行为的问题;我很想看到像DanielWagner这样的其他答案,专门针对IO monad。

答案 3 :(得分:1)

涉及IO并不重要。你可以在Haskell wiki中看到它:

IO inside

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

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