Lecture Notes for Com Sci 221, Programming Languages
Last modified: Fri Jan 27 15:17:20 1995
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 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.
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>
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
T or <alpha> ==> T F or <alpha> ==> <alpha> T and <alpha> ==> <alpha> F and <alpha> ==> F
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>
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
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
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 infib(n) ==> if n=0 or n=1 then 1 else fib(n-1) + fib(n-2) fi
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.square(<alpha>) ==> <alpha> * <alpha>
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
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>
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)
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>
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
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 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)
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.