With a normal programming language, it's theoretically possible to write a static analyzer that determines a program's maximum stack usage, with the caveat that for some programs, the analyzer will answer “I don't know”.
Proof that it's possible: “always answer ’I don't know‘” is a correct (if boring) solution. Proof that the caveat is necessary if there is a way to write programs that overflow the stack: for any static analyzer, there exists a subprogram undecidable()
such that the analyzer cannot decide whether it returns true or false. The analyzer cannot analyze the stack usage of if undecidable() then cause_stack_overflow() else do_nothing()
.
So the interesting questions are:
- Are there programming languages that are still Turing-complete (i.e. that can express everything we think of as (sequential) computation), but don't have a stack as such?
- If you want memory usage analysis to always be possible, what computing power do you lose?
- Can stack usage analysis be practical?
Programming without a stack
Arbitrary computations require the ability to write programs that can take arbitrarily long and use arbitrary amounts of memory. That memory doesn't have to be organized as a stack, however. Suppose you have a programming language with a stack (whatever the stack exactly is — typically at least a function call stack). Let's write an interpreter for it in the form of a while loop that executes one instruction at a time. If the interpreter needs to remember information about past instructions (for example a point to jump back to), it allocates an object in its heap. The interpreter itself doesn't have any function calls. Thanks to this interepreter, you now have a programming language that's executed without a stack.
Of course, if the original language had something like function calls, the abstract structure of its call stack still exists in the interpretation. But now it consists of links between heap objects, not directly a stack structure in memory.
What is theoretically necessary is the ability for memory usage to grow over the execution of the program, not that this growth involes a stack structure. Practically though, most programming languages have something like function calls, so they have a function call stack. This is because [functions]https://en.wikipedia.org/wiki/Function_(computer_programming)) (or subroutines, procedures, however you want to call it) are a very convenient way to have some composability, where you can write a piece of code and reuse it. If you've ever tried programming in an esoteric programming language without composability, you probably felt the pain.
What I've shown informally here is that imperative programming with heap allocation is as powerful (in terms of computability) as functional programming with recursion. The converse is true: both are common ways of achieving Turing completeness.
Programming in bounded memory
We've seen that you can “hide” a stack in some memory that isn't organized as a stack. Can we forbid that as well? What happens if we prevent programs from using arbitrary amounts of memory?
If a programming language only allows programs to use a fixed amount of memory, then all the programs are running on a finite automaton. The theoretical computing power of finite automata is very restricted. All it can do is recognize whether inputs match a regular expressions. Even very simple things such as determining whether a string has balanced parentheses become impossible.
Of course, in practice, we run programs on computers that have a finite amount of memory. But we don't reason about the finite memory as such, because it's intractable and extremely ad hoc. When we wonder “does my program work correctly?”, we don't want to get into the details that some particularly convoluted input only works if the computer has at least that many GB or RAM, and only if such-and-such program isn't running at the same time, and only if the heap wasn't fragmented by some previous processing, etc. (The exception is some high-reliability systems where we're willing to give up flexibility for certainty, which I'll come back to later.) So practical reasoning about programs involves theoretical models where if you run out of RAM, you just add more.
Coming back to theory, we've seen that if you impose that every program must use no more than some fixed amount of memory, your computing model is limited to finite automata. But you can have programs whose memory usage is statically analyzable, yet not statically bounded. For example, a common pattern is that a program will use a predictable amount of memory, but the amount depends on the input. If you follow this pattern that a program's resource usage can be unbounded but must be predictable, you get primitive recursive functions. Primitive recursion allows nesting function calls and loops, but you have to announce the number of calls or iterations in advance. That number can itself be a runtime calculation, but that calculation has to be bounded as well, and so on.
Not all computation can be done with primitive recursion. Turing completeness requires primitive recursion plus some form of unbounded iteration, such as a while loop or unbounded recursion. However, a lot of algorithms are primitive recursive because the number of iterations can be bounded by the size of some data structure.
Bounding stack usage in practice
In embedded programming, it's common to run programs in very small stacks. It's also sometimes inconvenient to detect stack overflow. Or even if a stack overflow can be detected, you can't recover from it, you can only halt the system. If this is a high-reliability system, you definitely don't want to do that. And so in embedded development, it's fairly common to run a static checker to ensure that the program will not overflow the stack. This is a whole-program check that of course depends on the exact platform you're deploying to and the details of how the compiler optimizes your program.
In order for the stack usage analysis to be practical, recursion is forbidden. If you have a problem that lends itself to a stack, such as searching for a value in a tree, you have to manage memory manually. If this involves heap allocation, you have to handle out-of-memory errors explicitly. Function pointers and dynamic dispatch are forbidden or at least restricted to what your analyzer supports.
Such static checkers exist for languages typically used in low-level programming. For example gcc -fstack-usage
works for C, C++ and other languages supported by GCC. I don't know how practical it is with C++ where dynamic dispatch tends to happen far more often than in C.