Don’t worry quite so much about the stack. There is nothing fundamental that says function calls have to be implemented using stack frames; that is merely one possible technique for implementing them.
Even when you have “the stack”, there’s certainly nothing that says the stack has to be limited to a small fraction of available memory. That’s essentially a heuristic tuned to imperative programming; where you don’t use recursion as a problem solving technique, very deep call stacks tend to result from infinite-recursion bugs, and limiting the stack size to something quite small means such programs die quickly instead of consuming all available memory and swap and then dying.
To a functional programmer, having a program terminate “run out” of memory to make more function calls when the computer still has gigabytes of RAM available is a ridiculous flaw in the language design. It would be like C limiting loops to some arbitrary number of iterations. So even if a functional language is implementing function calls by using a stack, there would be a strong motivation to avoid using the standard tiny stack we know from C if possible.
In fact, Haskell does have a stack which can overflow, but it’s not the call stack you’re familiar with from C. It is quite possible to write non-tail-recursive functions which infinitely recurse and will consume all available memory without hitting a limit on call depth. The stack Haskell does have is used to track the “pending” values that need to be evaluated a bit more in order to make a decision (I’ll go into this a bit more later). You can read in more detail about this kind of stack overflow here.
Lets work through an example to see how your code could be evaluated.1 I’ll use an even simpler example than yours though:
main = do
input <- getLine
if input == "quit"
then
putStrLn "Good-bye!"
else do
putStrLn $ "You typed: " ++ input
main
Haskell’s evaluation is lazy2. Simplistically, that means it will only bother to evaluate a term when it needs the value of that term to make a decision. For example, if I compute 1 + 1
and then prepend the result of that to the front of a list, it can be left as a “pending” 1 + 1
in the list3. But if I use if
to test whether the result was equal to 3, then Haskell will need to actually do the work of turning 1 + 1
into 2
.
But if that’s all there was to it, nothing would ever happen. The entire program would just be left as a “pending” value. But there is an outer driver that needs to know what IO action main
evaluates to, in order to execute it.
Back to the example. main
is equal to that do
block. For IO
, a do
block makes an a big IO
action out of a series of smaller ones, which must be executed in order. So the Haskell runtime sees main
evaluating to input <- getLine
followed by some unevaluated stuff which it doesn’t need yet. That’s enough to know to read from the keyboard and call the resulting String
input
. Say I typed “foo”. That leaves Haskell with something like the following as its “next” IO
action:
if "foo" == "quit"
then
putStrLn "Good-bye!"
else do
putStrLn $ "You typed: " ++ "foo"
main
Haskell is only looking at the very outermost thing, so this pretty much looks like “if blah blah blah blah …”. if
isn’t something that the IO-executor can do anything with, so it needs to be evaluated to see what it returns. if
just evaluates to either the then
or the else
branch, but to know which decision to make Haskell is required to evaluate the condition. So we get:
if False
then
putStrLn "Good-bye!"
else do
putStrLn $ "You typed: " ++ "foo"
main
Which allows the whole if
to be reduced to:
do
putStrLn $ "You typed: " ++ "foo"
main
And again, do
gives us an IO
action which consists of an ordered sequence of sub-actions. So the next thing the IO-executor has to do is the putStrLn $ "You typed: " ++ "foo"
. But that’s not an IO
action either (it’s an unevaluated computation that should result in one). So we need to evalute it.
The “outermost” part of putStrLn $ "You typed: " ++ "foo"
is actually $
. Getting rid of the infix operator syntax so you can see it the same way the Haskell runtiem does, it would look like this:
($) putStrLn ((++) "You typed: " "foo")
But the $
operator just defined by ($) f x = f x
, so substituting the right hand side immediately gives us:
putStrLn ((++) "You typed: " "foo")`
Now ordinarily we’d evaluate this by substituting in the definition of putStrLn
, but it’s a “magic” primitive function that isn’t directly expressible in Haskell code. So it doesn’t actually get evaluated like this; the outer IO-executor simply knows what to do with it. But it requires that the argument of putStrLn
be fully evaluated, so we can’t leave it as (++) "You typed: " "foo"
.
There’s actually a number of steps to fully evaluate that expression, going through the definition of ++
in terms of list operations, but lets just skip over that and say it evaluates to "You typed: foo"
. So then the IO-executor can execute the putStrLn
(writing the text to the console), and move on to the second part of the do
block, which is just:
`main`
Which is not something that can be immediately executed as an IO
action (it’s not built in to Haskell like putStrLn
and getLine
are), so we evaluate it by using the right hand side of the definition of main
to get:
do
input <- getLine
if input == "quit"
then
putStrLn "Good-bye!"
else do
putStrLn $ "You typed: " ++ input
main
And I’m sure you can see where the rest is going.
Note that I haven’t said anything about any kind of stack. All of this is just building up a data structure describing the IO
action that is main
, so the outer driver can execute it. It’s not even a particularly special data structure; from the point of view of the evaluation system it’s just like any other data structure, and so there are no arbitrary limitations on its size.
In this case lazy evaluation means the generation of this data structure is interleaved with its consumption (and the generation of later parts of it can depend on what happened as a result of consuming earlier parts of it!), and so this program can run in a constant amount of space. But as noted by shachaf’s comment on the question, this isn’t really an optimization for removing unnecessary stack frames; it’s just what happens automatically with lazy evaluation.
So I hope that was sufficiently helpful for you to see what’s going on. Basically by the time Haskell gets to evaluating the recursive call to getCommandsFromUser
, it’s already done with all of the data generated within the previous iteration, and so it gets garbage collected. So you can keep running this program indefinitely without needing more than a fixed amount of memory. This is just a straightforward consequence of lazy evaluation, and isn’t substantially different when IO
is involved.
1 I’m going to disclaim up front that I do not know much in detail about the actual current implementation of Haskell. I do however know general techniques for implementing lazy pure languages like Haskell. I’m also going to try to avoid diving too much into the details, and just explain how things work in an intuitive way. So this account may well be incorrect in some of the fine details of what’s actually going on inside your computer, but it should show you how these things can work.
2 The language spec technically just says that evaluation should be “non-strict”. The evaluation I’m going to describe, which is known as “lazy” informally, is really only one possible “non-strict” evaluation strategy, but it’s what you get in practice.
3 And the new list can in fact be left as a “pending” result of (1 + 1) : originalList
until someone needs to know whether or not it’s empty.