不必过于关注栈。没有什么本质的要求函数调用必须使用栈帧来实现;那只是一种可能的技术实现方式。
即使你有“栈”,也没有什么规定栈必须限制在可用内存的一小部分上。这基本上是一种针对命令式编程进行调优的启发式方法;在命令式编程中,如果不使用递归作为问题解决技巧,则非常深的调用堆栈往往会导致无限递归错误,并且将栈大小限制为相当小的东西意味着这样的程序会迅速死亡而不是消耗所有可用的内存和交换空间然后死亡。
对于函数式程序员来说,在计算机仍然有几GB的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
的工作。
但如果只有这些,什么都不会发生。整个程序将只剩下一个“挂起”值。但是有一个外部驱动程序需要知道main
评估为什么IO操作,以便执行它。
回到这个例子。 main
等于那个 do
块。对于IO
,do
块将一系列较小的IO
操作组成一个大的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执行程序来说无法做任何事情,因此需要对其进行评估以查看其返回值。 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
动作(action)。所以,IO 执行器需要执行的下一步是 putStrLn $ "You typed: " ++ "foo"
。但这也不是一个 IO
动作(它是一个未求值的计算结果,应该最终得到一个动作)。因此,我们需要对其进行求值。
putStrLn $ "You typed: " ++ "foo"
的“最外层”部分实际上是 $
。为了使您能够像 Haskell 运行时一样清晰地看到它,我们需要消除中缀运算符语法,它应该是这个样子:
($) putStrLn ((++) "You typed: " "foo")
但是,$
运算符只是由 ($) f x = f x
定义的,因此立即用右侧进行代换,我们得到:
putStrLn ((++) "You typed: " "foo")`
通常情况下,我们会通过替换putStrLn
的定义来评估它,但它是一种“魔法”原始函数,在Haskell代码中不能直接表达。 所以它不会像这样被评估; 外部IO执行器只需知道如何处理它。 但它要求putStrLn
的参数被完全评估,因此我们不能将其保留为(++)"You typed: " "foo"
。
实际上,需要多个步骤才能完全评估该表达式,需要经过在列表操作中使用++
的定义,但让我们跳过这些细节并说它评估为"You typed: foo"
。 然后,IO执行器可以执行putStrLn
(将文本写入控制台),然后转到do
块的第二部分,即:
`main`
这不是像 putStrLn
和 getLine
一样内置于 Haskell 中立即执行的 IO
操作,因此我们通过使用 main
的定义的右侧来评估它:
do
input <- getLine
if input == "quit"
then
putStrLn "Good-bye!"
else do
putStrLn $ "You typed: " ++ input
main
我相信你能看出接下来的内容发展方向。
需要注意的是,我并没有提到任何关于堆栈的东西。所有这些都只是构建一个描述main
的IO
操作的数据结构,以便外部驱动程序可以执行它。它甚至不是一个特别特殊的数据结构。从评估系统的角度来看,它就像任何其他数据结构一样,因此对其大小没有任何任意的限制。
在这种情况下,惰性评估意味着生成该数据结构与其消耗交错(并且生成其后续部分可能取决于消耗其先前部分所产生的结果!),因此此程序可以在恒定的空间中运行。但是如shachaf在问题评论中指出的那样,这并不是用于删除不必要的堆栈帧的优化;它只是惰性评估自动发生的结果。
所以我希望这足够有助于您了解正在发生的事情。基本上,当Haskell开始评估对getCommandsFromUser
的递归调用时,它已经完成了上一次迭代内生成的所有数据,因此会进行垃圾回收。因此,您可以无限期运行此程序,而不需要超过固定量的内存。这仅仅是惰性评估的直接结果,在涉及IO
时并没有实质性区别。
1我要提前声明,我对Haskell的实际当前实现并不了解。但是我知道实现像Haskell这样的惰性纯语言的一般技术。我也将尝试避免过多地深入细节,并以直观的方式解释事情的工作原理。因此,这个描述可能在计算机内部实际发生的某些详细信息方面不正确,但它应该向您展示这些事情如何可以工作。
2语言规范技术上只说评估应该是“非严格”的。我要描述的评估(通俗称为惰性)实际上只是一种“非严格”的评估策略之一,但它是实践中得到的。
3而新列表实际上可以作为(1 + 1):originalList
的“待定”结果保留,直到有人需要知道它是否为空。
(>>=)
右侧的内容。实际上,对于许多单子而言,它并不适用。但在IO中,我们会非常小心地确保它适用。@ThomasM.DuBuisson - Carl