The source for this post is online at 2013-07-08-stack.rkt.
It is common for programming language implementations to use stacks to implement function call and return. It is also common for novice programmers to misunderstanding the meaning of stacks in this context. This post explains how they are used and what the common misunderstanding is.
Our programming languages are implemented by compilation to low-level CPU languages that lack almost all forms of abstraction including functions. Functions encapsulate many forms of abstraction, but the main one is control-flow. The code (begin (f) (g)) abstracts executing the code of f followed by g, as opposed to directly inserting that code into the begin block.
In order to demonstrate this better, let’s use a little interpreter for a CPU-like language. In this language, we’ll have a small number of instructions and a bunch of registers.
This program performs two additions and returns the result:
(check-equal? (cpu (LOAD 0 5) (LOAD 1 7) (ADD 2 0 1) (LOAD 3 8) (ADD 4 2 3) (EXIT 4)) '(20))
The LOAD instruction has a destination register first and a value second. The ADD instruction has the destination register and then the source registers. EXIT has a status source register as its argument.
This program has no control-flow other than "execute the next instruction after this one". If we had GOTO and labels, then we could write a program like this:
(check-equal? (cpu (GOTO main) (LABEL second) (LOAD 3 8) (ADD 4 2 3) (EXIT 4) (LABEL first) (LOAD 0 5) (LOAD 1 7) (ADD 2 0 1) (GOTO second) (LABEL main) (GOTO first)) '(20))
However, GOTOs like this aren’t really like function calls, because the first block directly calls the second block. We really want code more like:
(check-equal? (cpu (GOTO main) (LABEL second) (LOAD 3 8) (ADD 4 2 3) (EXIT 4) (LABEL first) (LOAD 0 5) (LOAD 1 7) (ADD 2 0 1) (RETURN) (LABEL main) (CALL first) (CALL second)) '(20))
What’s the difference between CALL and GOTO? A CALL is a GOTO that records its next instruction on a stack. In the interpreter, it runs
(push! stack (add1 PC))
before it jumps to the given label. In contrast, RETURN rather than jumping to (add1 PC) jumps to
which was previously pushed by the CALL.
In our example program, when the code for first is running, the stack contains a program address from the main block. main called first so it may seem that the stack contains an abstraction of the past of the computation.
This idea inspires many novice programmers to believe that stack traces (read-outs of the stack) are useful tools for determining how the currently running code started executing, i.e. its history.
However, this is incorrect.
The stack does not record the past of the computation, it only records its future. The address that CALL pushes on to the stack has never been executed and will only execute when RETURN runs. If the past of the computation contains any branches that have already returned, then the stack will hide these, such as in the code for second.
We can change our interpreter to remember the exact program history and allow us to export it (along with the stack) to compare them.
(check-equal? (cpu (GOTO main) (LABEL second) (EXPORT 2nd) (LOAD 3 8) (ADD 4 2 3) (EXIT 4) (LABEL first) (EXPORT 1st) (LOAD 0 5) (LOAD 1 7) (ADD 2 0 1) (RETURN) (LABEL main) (CALL first) (CALL second)) '((1st (history 7 6 13 12 0) (stack 14)) (2nd (history 2 1 14 11 10 9 8 7 6 13 12 0) (stack 15)) 20))
As you can see, in the history of the second export, you can see that first (PC 6–11) is recorded, but the stack only contains main (PC 12–14).
Once we realize that stacks are future and not history, tail-call optimization becomes much more clear: if a computation adds nothing to the future, it doesn’t need to add anything to the stack.
For example, in this program, first calls second and does nothing with the result:
It is trivial to recognize this situation, because it corresponds to a computation that contains the sequence (CALL label) (RETURN). We can just change it to (GOTO label).
We can see that in the history of the second version, it doesn’t execute PC 9 after 12, but more important it doesn’t allocate any stack space for the call inside first.
But first let’s remember what we learned today!
CPUs don’t give you functions, but they do give you instructions like CALL and RETURN which can be used to implement functions.
The stack (as used by CALL/RETURN) records the future of your computation. Stack traces are, therefore, a reading of a potential future (only potential because of real-world features like exceptions and continuations) and have nothing to do with the past of the computation.
If you want to know the history of your computation, you have
to get it some other way—
If you’d like to run this exact code at home, you should put it in this order:
(require racket/match racket/list data/gvector) (define-syntax-rule (cpu inst ...) (cpu-execute (vector 'inst ...))) (define-syntax-rule (push! stack v) (set! stack (cons v stack))) (define-syntax-rule (pop! stack) (begin0 (first stack) (set! stack (rest stack)))) (define (cpu-execute iv) (define label->PC (make-hasheq)) (for ([i (in-vector iv)] [PC (in-naturals)]) (match i [`(LABEL ,l) (hash-set! label->PC l PC)] [x (void)])) (define registers (make-gvector)) (define (rref r) (gvector-ref registers r)) (define (rset r v) (gvector-set! registers r v)) (define stack empty) (define history empty) (let loop ([PC 0]) (push! history PC) (match (vector-ref iv PC) [`(LOAD ,r ,v) (rset r v) (loop (add1 PC))] [`(ADD ,d ,l ,r) (rset d (+ (rref l) (rref r))) (loop (add1 PC))] [`(EXIT ,r) (list (rref r))] [`(LABEL ,l) (loop (add1 PC))] [`(GOTO ,l) (loop (hash-ref label->PC l))] [`(CALL ,l) <call-code> (loop (hash-ref label->PC l))] [`(RETURN) (loop <return-code>)] [`(EXPORT ,id) (cons (list id (cons 'history history) (cons 'stack stack)) (loop (add1 PC)))]))) (require rackunit) <basic> <basic/goto> <basic/call> <basic/history> <tco/before> <tco/after>