[CS Dept logo]

Evaluating Expressions

Lecture Notes for Com Sci 221, Programming Languages

Last modified: Fri Jan 27 15:17:20 1995


Comments on these notes

Continue Friday 20 January

What is an expression?

In conventional procedural programming languages, such as C and Pascal, the conditions in while, for, and if commands, and the descriptions of values on the right-hand sides of assignment commands, are generally thought of as expressions. In functional programming languages, such as Scheme and ML, the entire program is one large expression. In Prolog, the arguments to relations are expressions, but they are typically quite small, and they are not intended to be evaluated in the same way as expressions in most other languages. For our purposes, expressions are those components of a program that are usefully understood in terms of their evaluation to a single value. As with essentially all technical terms in this course, the word "expression" is defined in particular languages in ways that are not exactly the same as our use in class. For example, in official descriptions of C, assignment commands are allowed to occur in expressions.

In principle, it is possible to regard an entire program in any language as one large expression, whose value is some description of the total output of the program. But, commands involving the assignment of values to variables are usually thought of as modifying the state of a computation, rather than evaluating to a value. For the most part, we will consider as "expressions" only components of a program whose primary (and preferably sole) function is to evaluate to a value in some standard datatype of the programming language, such as integer, boolean, array, etc.

To understand expression evaluation, it is better to regard each expression as a syntax tree, rather than a sequence of symbols. The issues of precedence, associativity, etc., that are so important in parsing can be dealt with entirely in the translation of a sequence of symbols into a syntax tree. Combining parsing issues with evaluation is a common methodological error, and it leads to a lot of confusion. When we are discussing expression evaluation, it is crucial that you mentally translate all textual expressions into tree form. I repeatedly see students make errors by evaluating a portion of the textual presentation of an expression that is not a sensible component of the syntax tree. You are so familiar with arithmetic that you would probably never make the mistake of evaluating the 2+3 in 2+3*4 to 5, yielding 5*4 and finally, 20. But, as soon as you consider expressions with less familiar data types and operators, it is very easy to make comparable errors.


End Friday 20 January
%<----------------------------------------------
Begin Monday 23 January

Expression evaluation as tree rewriting

Expression evaluation is simply the computation of the value of an expression. Maybe that sentence is too obvious to be worth writing. The power of expression evaluation as a methodological concept in computing, is that for most purposes, an expression may be thought of as equal to its value. So, an expression may be replaced by its value without changing the meaning of other things that contain that expression.

Expressions contain other expressions (called subexpressions) as components. Based on the principle above, we may replace any subexpression with its value, and not change the value of the whole expression. So, expression evaluation may proceed by replacing subexpressions with values, until we find a way to replace the whole expression by its values. This is a sort of tree rewriting (since the expressions are all presented as trees). Many programming languages violate the principle of replacability of subexpressions by their values (e.g., a C expression containing an assignment command violates this principle). Nonetheless, the best way to understand expression evaluation is to first conceive it as pure tree rewriting, and then consider variations from that relatively simple concept. Many aspects of programming languages are best understood as close approximations to something simple and elegant, with the deviations brought in only as necessary to find the right results.

Simple arithmetic evaluation

The simplest sorts of tree rewriting rules replace an operator applied to completely evaluated arguments by the final value. Almost all arithmetic and boolean expressions in the style of C and Pascal may be evaluated by such rules. For example, the rule for evaluating 2+3 is shown in Figure 10.


if T then <alpha> else <beta> ==> <alpha>
if F then <alpha> else <beta> ==> <beta>

We most often thing of conditional as a control-flow construct in Pascal and C, but it is also an operator in expressions in many languages, including Scheme and ML. The rules for conditionals are more sophisticated than those for the arithmetic operations, because they involve metavariables standing for arbitrary subexpressions. I have presented these metavariables as Greek letters to emphasize the fact that they are not actual symbols in an expression.

There is essentially only one way to apply the simple arithmetic rules for +, *, etc. We must replace subexpressions by values starting from the leaves of an expression, and work our way up to the root. Sure, we can choose to go left-to-right, right-to-left, or some other order across the leaves, but that choice is not particularly crucial (it may affect the intermediate storage required by expression evaluation, so some compilers may try to optimize it). Evaluation from the leaves up is called innermost evaluation, since in a parenthesized presentation of an expression it works within the innermost parentheses. The conditional rules, however, may apply arbitrarily far up a tree, since the subexpressions <alpha> and <beta> may be arbitrarily large. The order in which we apply the arithmetic and conditional rules does not affect the final value, but it may affect the number of steps required to compute that value very significantly. If we evaluate the then branch of a conditional, only to find that the else branch is selected, we may waste huge amounts of time. Later, when we introduce rules that behave iteratively or recursively, we may even waste an infinite amount of time, and fail to produce the final value at all. It is intuitively clear (and correct, too) that, in the presence of arithmetic rules and conditionals, when we see a conditional we should evaluate its leftmost argument first to T or to F, then apply one of the conditional rules to prune the branch not taken, and only then should we evaluate the remaining branch. This order of evaluation is called leftmost-outermost. We may still evaluate the purely arithmetic portions of an expression innermost, while using the leftmost-outermost strategy on conditionals.

At first, essentially all expression evaluation in programming languages involved simple rules, such as the arithmetic rules but involving other operators and data types as well, plus the conditional rules. The simple rules were always applied innermost, and the conditional rule was a special exception calling for leftmost-outermost evaluation. Boolean operations were evaluated using the obvious analogues to the arithmetic rules, as shown in Figure 11.


T or T ==> T
T or F ==> T
F or T ==> T
F or F ==> F

T and T ==> T
T and F ==> F
F and T ==> F
F and F ==> F

Then, some people noticed that conditional evaluation might be valuable in other contexts, leading to the conditional or and conditional and rules of Figure 12.
T or <alpha> ==> T
F or <alpha> ==> <alpha>

T and <alpha> ==> <alpha>
F and <alpha> ==> F

As with the conditional if rules, the conditional and and or rules must be applied leftmost-outermost in order to have extra power: applied innermost they are equivalent to the simple and and or rules. C has both the simple or and and, and the conditional versions. Having realized that operators, such as or and and, might evaluate to sensible final values even though some of their arguments were not evaluated (and might conceivably be unevaluable), people started calling the conventional operators strict, and the other ones such as conditionals nonstrict.

Lazy evaluation

Conditional evaluation merely scratches the surface of imaginative uses of tree rewriting rules. The LISP/Scheme operators cons, car, and cdr were originally conceived as simple arithmetic-like operators operating over the data type of nested lists, or equivalently of S-expressions. Having seen the rules for conditional, several people (including me) realized that these operators would be more powerful if evaluated by the rules in Figure 13.


car(cons(<alpha>,<beta>) ==> <alpha>
cdr(cons(<alpha>,<beta>) ==> <beta>

As before, innermost use of these rules gives no additional power, but applying them outermost gives us demand-driven evaluation of lists, which is popularly called "lazy evaluation," although I don't regard avoiding absolutely wasted work as lazy. Because functions are conventionally thought of as subroutines that are called to compute values from arguments, lazy evaluation is also referred to as "call by need" (understood as an alternative to call by value, call by name, etc., which we study later). This is somewhat misleading, since outermost evaluation changes the sorts of structures returned by a function more than the way that arguments are passed to it. To contrast with lazy evaluation, innermost evaluation is sometimes called "eager."

Lazy evaluation allows very large, and even infinite, lists to be defined as if they were intermediate steps in a computation. But, only the elements of lists that affect the final output are actually computed. Scheme has streams, which are just lazily evaluated lists, but it uses new operators instead of car, cdr, cons. The stream operations in Scheme are the result of a language that never conceived of lazy evaluation (LISP) being stretched to allow it. I would like to see languages re-engineered to be lazy from the bottom up. Haskell is an experimental language that is founded on lazy evaluation, although it is still not radical enough for my taste.

If you look real closely, you will see a formal analogy between the two rules for T, F, if and the two rules for car, cdr, cons. A mathematician from Mars, who knew the basic notation of mathematics, but not the conventional meanings of the words if, etc., might say that the two pairs of rules have almost the same structure. In both cases, there are two unknowns on the left, and one of them is selected on the right, based on the identity of a single symbol. In the if rules, that symbol is the constant symbol T or F in the first argument position to the if, in the cons rules it is the unary function symbol car or cdr that is applied to the cons. That difference hardly looks profound from a formal point of view. So, it is particularly ironic that computer scientists went for so many years assuming that if and cons required radically different sorts of implementations.


End Monday 23 January
%<---------------------------------------------- Begin Wednesday 25 January

Pushing the envelope with evaluation rules

Ph.D. dissertations have been written on the powers of tree rewriting rules for expression evaluation (e.g., my dissertation in 1976), and several more dissertations remain to be written on the topic. The examples in this section are just intended to give a sense of the range of possibilities.

First, programmer-defined recursive functions may be understood as additional evaluation rules added by the programmer. For example the conventional recursive definition of the Fibonnacci function corresponds to the rule

fib(n) ==> if n=0 or n=1 then 1
              else fib(n-1) + fib(n-2) fi
The rules corresponding to programmer-defined functions are usually quite simple, but because they are allowed to be recursive, they introduce the possibility of infinite computation, and make the order of evaluation particularly important. Most of the complexity of evaluation rules comes from the patterns in their left-hand sides. Programmer-defined functions usually have simple left-hand sides, but they do introduce the minor complication of repetitions on the right-hand sides, as in
square(<alpha>) ==> <alpha> * <alpha>
For efficiency, when applying outermost evaluation to such a rule, it is crucial not to make a new copy of <alpha>, but merely to create two pointers to a shared single copy. Many people use the phrase "lazy evaluation" to mean outermost evaluation with sharing, although there is no official standard definition.

Conditional evaluation can be applied much more broadly than it has been. For example,


0*<alpha> ==> 0
1*<alpha> ==> <alpha>

Many of the operators that benefit from the power of conditional evaluation can be taken much farther. For example, the parallel or rules (and the analogous parallel and) are even more powerful than the conditional ones:


T or <alpha> ==> T
<alpha> or T ==> T
F or F ==> F

Notice that there is no sensible way to choose the left or right argument to or for first evaluation---rather we must evaluate both of them in parallel. There is no way to avoid some wasted work when one of the first two rules applies. Also notice that it is the two rules involving T that cause the complication (and the power)---the third F rule is just like the simple arithmetic rules.

If or and and can be parallel, why not if?


if <gamma> then <alpha> else <alpha> ==> <alpha>
if T then <alpha> else <beta> ==> <alpha>
if F then <alpha> else <beta> ==> <beta>

These rules require parallel evaluation of the condition and both branches of an if, plus an equality test between the two branches. The equality test, which results from the repetition of <alpha> on the left-hand side of the first rule, is especially tricky, and its theoretical properties might fill a dissertation by themselves. The pioneering lazy language Daisy, from Indiana University, had a kind of parallel if with the ingenious use of distributive rules:
if <gamma>
      then cons(<alpha>1,<beta>1)
      else cons(<alpha>2,<beta>2)

                  ==>

cons(if T then <alpha>1 else <alpha>2,
    if T then <beta>1 else <beta>2)

This brilliantly clever rule allows the equality test to be performed only when at least one of the branches has been reduced to an atomic symbol.

The possibility of implicit equality tests on the right-hand side suggests several powerful rules, such as


<alpha> = <alpha> ==> T

<alpha> - <alpha> ==> 0

<alpha> ? <alpha> ==> <alpha>

Strangely, the third one is somewhat easier to deal with, because the <alpha> has to be evaluated in any case. But, these rules have already taken us beyond the realm of even experimental implementations. When we look at the difference between FORTRAN and Daisy, however, we should be very open to the possibility that even more sophisticated evaluation rules will find practical application in the future.

Implementing expression evaluation

The main purpose of the tree rewriting rules above is to help us understand evaluation. Most implementations of expression evaluation do not apply tree rewriting directly (although one promising approach to parallel evaluation does so---in this context it is called "graph rewriting" to emphasize the importance of sharing of subexpressions). Most evaluation is still bottom up, and it may be done by a simple recursion on the expression in syntax tree form:


eval(T):

      if T is a constant
         then return(value(T))
      else if T is a variable
         then return(current-value(T))
         else let T=op(T1,...,Tn)
              v1 := eval(T1)

                  .
                  .
                  .

              vn := eval(Tn)
              return(op(v1, ..., vn))
      fi

I have cheated a bit more than usual in my notation above. The use of op in the final return isn't quite right. op is a symbol, and in the final return I need to apply the subroutine associated with that symbol. A legitimate way to write that is return(value(op)(v1, ..., vn)), but most people get confused by that notation. All of this can be done in your favorite programming language, although the details may be quite ugly looking in some languages.

The recursive evaluation above, with a lot of detailed improvement and variation, is the basic idea behind expression evaluation in programming language interpreters. For functional languages, such as Scheme and ML, expression evaluation is all there is, so the whole interpreter has a structure based on recursive evaluation.


End Wednesday 25 January
%<----------------------------------------------
Begin Friday 27 January

To get closer to efficient compiled implementations of instruction evaluation, we first convert the syntax tree into postfix form. The following iterative program uses a stack to evaluate an expression expr in postfix, operating from left to right:


start at the left end of expr, with an empty stack

while not at the right end of expr do

   if nextsym is a constant then

      push the value of nextsym on the stack

   else

      let nextsym represent the operation op with arity n

      replace the top n stack valued with op of them

   fi

now, the stack contains only one value,
   which is the value of expr

The iterative algorithm above is essentially the result of programming the recursion stack for the recursive algorithm explicitly. If the expression is computed dynamically at run time, or when it includes programmer-defined recursive operations, then the algorithm above is executed literally with an explicit stack. More often, when the expression is completely known at run time, and involves only operations defined by simple arithmetic-like rules, there is no need for an explicit implementation of the stack. Rather, we calculate the height of the stack very easily from the postfix expression, and translate each use of the stack into a reference to a specific memory location (usually a high-speed register). The form of the resulting machine code is very close to the postfix expression itself.

The example below shows an expression in infix and postfix notation, followed by idealized machine code that might be compiled to evaluate the expression, and finally a sequence of stack values that results when evaluating the expression. Read down the sequence of variables and operations in the machine code, and you see the postfix expression. In the sample execution, each column represents the contents of the stack after one of the machine operations. Arithmetic operations are shown between columns when appropriate. The register names are written in an addition column on the right, to show how each stack position corresponds to a register.


          infix

(x + 3) * y + 2 * (z + x)

          postfix

x3+y*2zx+*+

          machine code

Load R1 with    x
Set  R2  to     3
Add  R1  :=  R1 + R2
Load R2 with    y
Mult R1  :=  R1 * R2
Set  R2  to     2
Load R3 with    z
Load R4 with    x
Add  R3  :=  R3 + R4
Mult R2  :=  R2 * R3
Add  R1  :=  R1 + R2

          stack contents (with x=5, y=6, z=7)

                                5                  (R4)
                           7    7 + 12             (R3)
    3       6         2    2    2    2 * 24        (R2)
5   5 + 8   8 * 48   48   48   48   48   48 + 72   (R1)

A good programmer can certainly produce more efficient code than the example above. Typical compilers produce the simple, inefficient code first, then look for improvements.

As we use cleverer and cleverer evaluation rules, both the recursive and iterative methods above fail, and we are forced closer to an explicit application of tree rewriting for implementation. But, the basic iteration above may be stretched somewhat, at least to include conventional sorts of conditional evaluation.


Example to be typed later.

End Friday 27 January

The material above ended up a bit rushed, and mixed up with discussion of previous and current homework. We may discuss it more on Monday 30 January.