不要太担心堆栈。没有什么基本的东西说函数调用必须使用堆栈帧来实现。这只是实现它们的一种可能技术。
即使你有“堆栈”,也没有什么说堆栈必须限制在可用内存的一小部分。这本质上是一种针对命令式编程的启发式方法。如果您不使用递归作为解决问题的技术,那么非常深的调用堆栈往往是由无限递归错误导致的,并且将堆栈大小限制在非常小的范围内意味着此类程序会很快死亡,而不是消耗所有可用内存和交换然后死。
对于函数式程序员来说,当计算机仍然有千兆字节的 RAM 可用时,让程序终止“用尽”内存以进行更多的函数调用是语言设计中的一个荒谬的缺陷。这就像 C 将循环限制为任意数量的迭代。因此,即使函数式语言通过使用堆栈来实现函数调用,如果可能的话,也会有强烈的动机避免使用我们从 C 中知道的标准微型堆栈。
事实上,Haskell 确实有一个可以溢出的堆栈,但它不是你熟悉的 C 中的调用堆栈。很有可能编写无限递归的非尾递归函数,并且会消耗所有可用内存而不会碰到呼叫深度的限制。Haskell 确实拥有的堆栈用于跟踪需要更多评估才能做出决定的“未决”值(稍后我将详细介绍)。您可以在此处阅读有关这种堆栈溢出的更多详细信息。
让我们通过一个示例来了解如何评估您的代码。1我将使用一个比你的更简单的例子:
main = do
input <- getLine
if input == "quit"
then
putStrLn "Good-bye!"
else do
putStrLn $ "You typed: " ++ input
main
Haskell 的评估是懒惰的2。简单地说,这意味着它只会在需要该术语的值来做出决定时才费心评估该术语。例如,如果我计算1 + 1
然后将其结果添加到列表的前面,它可以1 + 1
在列表3中保留为“待处理” 。但是如果我if
用来测试结果是否等于 3,那么Haskell 就需要实际做转1 + 1
成2
.
但如果这就是它的全部,那么什么都不会发生。整个程序将仅保留为“待定”值。但是有一个外部驱动程序需要知道 IO 操作main
的评估结果,以便执行它。
回到例子。main
等于那个do
块。对于IO
,一个块由一系列较小的动作do
组成一个大动作,这些动作必须按顺序执行。IO
因此,Haskell 运行时看到main
评估input <- getLine
后是一些它还不需要的未评估的东西。这足以知道从键盘读取并调用结果String
input
。假设我输入了“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
只是评估then
或else
分支,但要知道做出 Haskell 的哪个决定需要评估条件。所以我们得到:
if False
then
putStrLn "Good-bye!"
else do
putStrLn $ "You typed: " ++ "foo"
main
这允许整体if
减少到:
do
putStrLn $ "You typed: " ++ "foo"
main
再一次,do
给了我们一个IO
由有序的子动作序列组成的动作。所以 IO 执行器接下来要做的就是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
动作执行的东西(它不像 Haskell 那样内置在里面putStrLn
)getLine
,所以我们使用 to 定义的右侧来评估它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
直到有人需要知道它是否为空。