Stacks

Course links

Abstract data types

An abstract data type is a set of values and operations on those values, considered independently of the ways in which those values might be represented and the operations implemented in actual programs. Separating the definition of an abstract data type from its implementation is a technique that has been found to be especially useful in the development of large software systems.

Objects produced by a constructor procedure, such as the switches in the reading on objects, can often be developed efficiently from the definition of an abstract data type, with the advantage that other procedures that take such objects as arguments cannot operate on them or modify them in any way not considered by the definition of the abstract data type. As a result, the programmer who wants to ensure that the object's invariants are always preserved can simply arrange for them to be true at the end of the execution of each method, provided that they are true at the beginning. Relying on such invariants often allows the programmer to dispense with precondition tests in the methods, because the invariants imply that the preconditions will be met whenever the method is called.

As illustrations of the use of abstract data types in the development of programs, we shall consider two frequently encountered data structures that Scheme happens not to supply as built-ins: stacks and queues.

Stacks as an abstract data type

Conceptually, the stack abstract data type mimics the information kept in a pile on a desk. Informally, we first consider materials on a desk, where we may keep separate stacks -- say, for bills that need paying, magazines that we plan to read, and notes we have taken. We can perform several operations that involve a stack:

These operations allow us to do all the normal processing of data at a desk. For example, when we receive bills in the mail, we add them to the pile of bills until payday comes. We then take the bills, one at a time, from the top of the pile and pay them until the money runs out.

When discussing these operations, it is conventional to call the addition of an item to the top of the stack a push operation and the deletion of an item from the top a pop operation. These terms are derived from the workings of a spring-loaded rack containing a stack of cafeteria trays. Such a rack is loaded by pushing the trays down onto the springs; as each diner removes a tray, the lessened weight on the springs causes the stack to pop up slightly.

Here is a more formal definition of the stack ADT: A stack is a data structure containing zero or more elements, on which the following operations can be performed:

This abstract data type definition says nothing about how we will program the various stack operations; rather, it tells us how stacks can be used. We can infer some limitations on how we can use the data. For example, stack operations allow us to work with only the top item on the stack. We cannot look at elements farther down in the stack without first using pop operations to clear away items above the desired one.

A push operation always puts the new item on top of the stack, and this is the first item returned by a pop operation. Thus, the last piece of data added to the stack will be the first item removed.

Stacks in Scheme

We can implement stacks in Scheme as objects that respond to the messages ':empty?, ':push!, ':pop!, and ':top. The create operation will correspond to the constructor procedure make-stack, which takes no arguments and returns an empty stack. The object will protect access to a static variable, stk, which will contain all of the elements that are currently in the stack, assembled into a list. Here is the code:

;;; make-stack: construct and return a stack

;; Givens:
;;   None

;; Result:
;;   STACK, a procedure

;; Preconditions:
;;   None.

;; Postconditions:
;;   (1) STACK is initially empty.
;;   (2) When STACK is invoked with the argument :EMPTY?, it
;;       reports whether it is empty.
;;   (3) When STACK has been invoked with the first argument
;;       :PUSH! and a second argument, say NEW-VALUE,
;;       NEW-VALUE is at the top (available for popping) and
;;       all other values on the stack are below it, in
;;       order by recency of having been pushed on.
;;   (4) When STACK has been invoked with the argument
;;       :POP!, it returns the value that was formerly at
;;       the top and retains all other values, in order by
;;       recency of having been pushed on.
;;   (5) When STACK is invoked with the argument :TOP, it
;;       returns the value at the top (available for popping).
;;   (6) It is an error to give STACK any other argument
;;       when invoking it.
;;   (7) When STACK is invoked with the first argument
;;       :EMPTY?, :POP!, or :TOP, it is an error to give it
;;       two or more arguments.
;;   (8) When STACK is invoked with the first argument
;;       :PUSH!, it is an error to give it only one argument
;;       or three or more.
;;   (9) When STACK is empty, it is an error to invoke it
;;       with the first argument :POP! or :TOP.

(define make-stack
  (lambda ()
    (let ((stk (make-vector 1 '())))
      (lambda (message . arguments)
        (let ((old-values (vector-ref stk 0)))
          (cond ((eq? message ':empty?)
                 (if (null? arguments)
                     (null? old-values)
                     (error (string-append
                              "stack:empty?: no "
                              "arguments are required")))) 

                ((eq? message ':push!)
                 (cond ((null? arguments)
                        (error (string-append
                                 "stack:push!: an "
                                 "argument is required")))
                       ((null? (cdr arguments))
                        (vector-set! stk
                                     0
                                     (cons (car arguments)
                                           old-values)))
                       (else
                        (error (string-append
                                 "stack:push!: only one "
                                 "argument is required")))))

                ((eq? message ':pop!)
                 (cond ((not (null? arguments))
                        (error (string-append
                                 "stack:pop!: no "
                                 "arguments are required")))
                       ((null? old-values)
                        (error (string-append
                                 "stack:pop!: "
                                 "the stack is empty")))
                       (else
                        (let ((removed (car old-values)))
                          (vector-set! stk 0 (cdr old-values))
                          removed))))
              
                ((eq? message ':top)
                 (cond ((not (null? arguments))
                        (error (string-append
                                 "stack:top: no "
                                 "arguments are required")))
                       ((null? old-values)
                        (error (string-append
                                 "stack:top: "
                                 "the stack is empty")))
                       (else
                        (car old-values))))

                (else
                 (error "stack: unrecognized message"))))))))

Since the field stk is allocated during the definition process, outside of the lambda-expression for the procedure being returned, it will persist as part of the object between operations on that object. Further, note that a different static variable is created each time make-stack is invoked. Thus, a program can arrange for the construction of any number of stacks, which can be pushed and popped independently.

Stacks are useful when it is necessary to interrupt or postpone part of a computation until some simpler or more urgent computation has been completed -- some description of the unfinished computation can be pushed onto a stack to make room for the simpler or more urgent one. When the latter is finished, we pop the stack to recover and resume the unfinished computation.

For example, suppose that we have a number tree (as described in the reading on deep recursion), and we want to find the sum of all the numbers in it. The approach we described there was to issue a recursive call every time we identified a non-empty subtree:

;;; sum-of-number-tree: find the sum of all of the elements
;;; of a number tree

;; Given:
;;   TR, a number tree.

;; Result:
;;   SUM, a number.

;; Preconditions:
;;   None.

;; Postcondition:
;;   SUM is the sum of all of the elements of TR.

(define sum-of-number-tree
  (lambda (ntree)
    (if (pair? ntree)
        (+ (sum-of-number-tree (car ntree))
           (sum-of-number-tree (cdr ntree)))
        ntree)))

This is an elegant solution, but the fact that Scheme does not specify the order in which the two recursive calls are made is sometimes inconvenient or confusing. (Some implementations of Scheme always evaluate the operands in a procedure call from left to right; other implementations always evaluate them from right to left; still others try to ``optimize'' the evaluation order in ways that can be quite difficult to fathom without detailed knowledge of the workings of the Scheme system.) We can resolve this difficulty by building up the sum as a running total, adding in the numbers one by one and considering only one subtree at any given time.

We can manage this by maintaining a stack that will at all times contain the subtrees whose contents we have not yet added to the running total. Initially, we'll set up the stack so that it contains the whole tree as its only element. Subsequently, at each step, we pop one subtree off this stack. If it is a number, we'll add it to the running total; if it is a pair, we'll take it apart into two number trees -- taking one from its car and one from its cdr -- and push them both onto the stack. Then we proceed to the next step. When the stack becomes empty, everything has been added to the running total, so we stop and return the accumulated value. In Scheme:

;;; sum-of-number-tree: find the sum of all of the elements
;;; of a number tree

;; Given:
;;   TR, a number tree.

;; Result:
;;   SUM, a number.

;; Preconditions:
;;   None.

;; Postcondition:
;;   SUM is the sum of all of the elements of TR.

(define sum-of-number-tree
  (lambda (ntree)
    (let ((to-do (make-stack)))
      (to-do ':push! ntree)
      (let kernel ((total 0))
        (if (to-do ':empty?)
            total
            (let ((current-subtree (to-do ':pop!)))
              (if (pair? current-subtree)
                  (begin
                    (to-do ':push! (cdr current-subtree))
                    (to-do ':push! (car current-subtree))
                    (kernel total))
                  (kernel (+ total current-subtree)))))))))

Here we control the order in which the subtrees are examined by specifying the order in which the :push! messages are sent. Because we push the cdr of a pair of subtrees onto the stack first, then the car, the car will be popped off first in the next step, so that its numbers will be added to the running total before the cdr is taken up at all. Thus the numbers in the tree will be processed from left to right. (Reversing the :push! messages would cause the numbers to be processed from right to left, since in that case the cdr of each pair subtree would be popped and added to the running total before the corresponding car.)

The idea of storing subproblems that we can't address immediately and recovering them later, when we're in a better position to solve them, is a useful programming strategy -- keep an eye out for appropriate occasions to use it.

Acknowledgements

The principal author of this reading is Professor Henry Walker. This version incorporates substantial revisions by the co-author named below.

We are indebted to Sanchit Chokhani 2005 for calling our attention to an editing error in an earlier version of this reading.