In CS 381, Programming Language Fundamentals, many students chose to implement a stack based language. Such languages are very neat, but two of the requirements for such languages may, at first, seem somewhat hard to satisfy:
Recursion/loops, . . . [and] . . . Procedures/functions with arguments (or some other abstraction mechanism)
A while-loop makes enough sense. The most straightforward way to implement such a loop is to keep reading a boolean from the stack, and, if that boolean is true, running some sequence of instructions. But while loops do not give you procedures - they are not a sufficiently powerful abstraction mechanism for this assignment. So, we turn to functions.
The first instinct in implementing functions is to fall back to the tried-and-true method of introducing more global state: we have a stack, but why don’t we also add a mapping from function names to their definitions (an environment)? This works, but I feel like it goes somewhat against the whole idea of a stack-based language. We can do everything we need to do, entirely on the stack!
A Toy Language
To make this post more concrete, let’s define a small language. Small enough that it’s easy to reason about, but complex enough to support functions. I won’t be giving a Haskell-encoded abstract syntax definition - rather, let’s work from concrete syntax. How about something like:
Let’s informally define the meanings of each of the described commands:
- : Removes the top elements from the stack.
- : Removes the top elements after the first element on the stack. The first element is not removed.
- : Pushes an element from the stack onto the stack, again. When , the top element is pushed, when , the second element is pushed, and so on.
- : Compares two numbers on top of the stack for equality. The numbers are removed, and replaced with a boolean indicating whether or not they are equal.
- : Pushes an integer onto the stack.
- : Adds two numbers on top of the stack. The two numbers are removed, and replaced with their sum.
- : Multiplies two numbers on top of the stack. The two numbers are removed, and replaced with their product.
- /: Runs the first list of commands if the boolean “true” is on top of the stack, and the second list of commands if the boolean is “false”.
- : pushes a function with the given commands onto the stack.
- : calls the function at the top of the stack. The function is removed, and its body is then executed.
Great! Let’s now write some dummy programs in our language (and switch to code blocks from LaTeX). How about a program that multiplies 4 and 5?
PushI 5
PushI 4
Mul
Next, let’s try something more complicated. [note: I'm aware that this example is contrived. To minimize the cognitive load of working with our language, I've stripped it of many useful features, including inequalities. This is why the example may seem strange: I had to pose a question I could answer! ]
PushI 4
PushI 3
Eq
if { PushI 999 } else { PushI 1 }
Now, it’s time for the actual meat: can our language do recursion? I claim that it does, but before we start hacking away, there’s one more thing we need to do: establish a calling convention.
Be Conventional!
Our language does not enforce any etiquette. You can easily create a function that pops every value off the stack, continuing until the stack is empty. You can equally easily make a function that fills your stack with random junk. With such potential for disorder, a programmer — maybe yourself — may experience some [note: Anomie is defined as "lack of the usual social or ethical standards in an individual or group" according to the Oxford dictionary. ] To deal with this, we try to maintain a little bit of order in the midst of all the computational chaos. We will adopt calling conventions.
When I say calling convention, I mean that every time we call a function, we do it in a methodical way. There are many possible such methods, but I propose the following:
- Since requires that the function you’re calling is at the top of the stack, we stick with that.
- If the function expects arguments, we push them on the stack right before the function. The first argument of the function should be second from the top of the stack (i.e., [note: Note that removes the function from the stack, which is why the first argument ends up at the very top. ] The second argument should follow, then the third, and so on.
- When a function returns, it should not leave its arguments on the stack. Instead of them, the function should leave its resulting value.
- A function does not modify the stack below the arguments it receives.
Let’s try this out with a basic function definition and call. How about a function that always returns 0, no matter what argument you give it? The function itself would look something like this:
PushI 0
Slide 1
Here’s how things will play out. When the function is called — and we assume that it is called correctly, of course – it will receive an integer on top of the stack. That may not, and likely will not, be the only thing on the stack. However, to stick by convention 4, we pretend that the stack is empty, and that trying to manipulate it will result in an error. So, we can start by imagining an empty stack, with an integer on top:
Then, will push 0 onto the stack:
will then remove the 1 element after the top element: . We end up with the following stack:
The function has finished running, and we maintain convention 3: the function’s return value is in place of its argument on the stack.
All that’s left is to call this function. Let’s try calling the function with the number 15. We do this like so:
PushI 15
func { PushI 0; Slide 1 }
Call
The function must be on top of the stack, as per the semantics of our language (and, I suppose, convention 1). Because of this, we have to push it last. It only takes one argument, which we push on the stack first (so that it ends up below the function, as per convention 2). When both are pushed, we use to execute the function, which will proceed as we’ve seen above.
Get Ahold of Yourself!
How should a function call itself? The fact that functions reside on the stack, and can therefore be manipulated in the same way as any stack elements. This opens up an opportunity for us: we can pass the function as an argument to itself! Then, when it needs to make a recursive call, all it must do is itself onto the top of the stack, then , and voila!
Talk is great, of course, but talking doesn’t give us any examples. Let’s walk through an example of writing a recursive function this way. Let’s try factorial!
The “easy” implementation of factorial is split into two cases: the base case, when is computed, and the recursive case, in which we multiply the input number by the result of computing factorial for . Accordingly, we will use the / command. We will make our function take two arguments, with the number input as the first (“top”) argument, and the function itself as the second argument. Importantly, we do not want to destroy the input number by running directly on it. Instead, we first copy it using , then compare it to 0:
Offset 0
PushI 0
Eq
Let’s walk through this. We start with only the arguments on the stack:
Then, duplicates the first argument (the number):
Next, 0 is pushed onto the stack:
Finally, performs the equality check:
Great! Now, it’s time to branch. What happens if “true” is on top of the stack? In that case, we no longer need any more information. We always return 1 in this case. So, just like the function I described earlier, we can do the following:
PushI 1
Slide 2
As before, we push the desired answer onto the stack:
Then, to follow convention 3, we must get rid of the arguments. We do this by using :
Great! The branch is now done, and we’re left with the correct answer on the stack. Excellent!
It’s the recursive case that’s more interesting. To make the recursive call, we must carefully set up our stack. Just like before, the function must be an argument to itself, and it’s found lower on the stack, so we push it first:
Offset 1
The result is as follows:
Next, we must compute . This is pretty standard stuff:
Offset 1
PushI -1
Add
Why these three instructions? Well, with the function now on the top of the stack, the number argument is somewhat buried, and thus, we need to use to get to it:
Then, we push a negative number, and add it to to the number on top. We end up with:
Finally, we have our arguments in order as per convention 2. To follow convention 1, we must now push the function onto the top of the stack:
Offset 1
The stack is now as follows:
Good! With the preparations for the function call now complete, we take the leap:
Call
If the function behaves as promised, this will remove the top 3 elements from the stack. The top element, which is the function itself, will be removed by the operator. The two next two elements will be removed from the stack and replaced with the result of the function as per convention 2. The rest of the stack will remain untouched as per convention 4. We thus expect the stack to look as follows:
We’re almost there! What’s left is to perform the multiplication (we’re safe to destroy the argument now, since we will not be needing it after this), and clean up the stack:
Mul
Slide 1
The multiplication leaves us with on top of the stack, and the function argument below it:
We then use so that only the factorial is on the stack, satisfying convention 3:
That’s it! We have successfully executed the recursive case. The whole function is now as follows:
Offset 0
PushI 0
Eq
if {
PushI 1
Slide 2
} else {
Offset 1
Offset 1
PushI -1
Add
Offset 1
Call
Mul
Slide 1
}
We can now invoke this function to compute as follows:
func { ... }
PushI 5
Offset 1
Call
Awesome! That’s about it. We have made a stack-based language with full support for recursion and procedures. I hope this was helpful.