Start Lecture #3
Remark: We will have a midterm. It will be 1 hour, during recitation. It will not be either next monday or the monday after.
Most languages use infix notation for built in operators. Scheme is an exception (+ 2 (/ 9 x)).
function "*" (A, B: Vector) return Float is Result: Float :=0.0; begin for I in A'Range loop Result := Result + A(I)*B(I); end loop; return Result; end "*";
Some languages permit the programmer to extend the infix operators. An example from Ada is at right. This example assumes Vector has already been declared as a one-dimensional array of floats. A'R gives the range of the legal subscripts, i.e. the bounds of the array. With loops like this it is not possible to access the array out of bounds.
Most languages agree on associativity, but APL is strictly right to left (no precedence, either). Normal is for most binary operators to be left associative, but exponentiation is right associative (also true in math). Why?
Languages differ considerably in precedence (see figure 6.1 in 3e). The safest rule is to use parentheses unless certain.
Homework: 1. We noted in Section 6.1.1 that most binary arithmetic operators are left-associative in most programming languages. In Section 6.1.4, however, we also noted that most compilers are free to evaluate the operands of a binary operator. Are these statements contradictory? Why or why not?
Assignment statements are the dominant example of side effects, where execution does more that compute values. Side effects change the behavior of subsequent statements and expressions.
There is a distinction between the container for a value
(the memory location
) and the value itself.
Some values, e.g., 3+4, do not have corresponding containers and,
among other things, cannot appear on the left hand side of an
assignment.
Otherwise said 3+4
is a legal r-value, but not a legal
l-value.
Given an l-value, the corresponding r-value can be obtained by
dereferencing.
In most languages, this dereferencing is automatic.
Consider
a := a + 1;the first a gives the l-value; the second the r-value.
begin a := if b < c then d else c; a := begin f(b); g(c) end; g(d); 2+3 end
The goal of orthogonality of features is to permit the features in any combination possible. Algol 68 emphasized orthogonality. Since it was an expression-oriented language, expressions could appear almost anywhere. There was no separate notion of statement. An example appears on the right. I have heard and read that this insistence on orthogonality lead to significant implementation difficulties.
I suspect you all know the C syntax
a += 4;It is no doubt often convenient, but its major significance is that in statements like
a[f(i)] += 4;it guarantees that f(i) is evaluated only once. The importance of this guarantee becomes apparent if f(i) has side effects, e.g., if f(i) modifies i or prints.
Some languages, e.g., Perl and Python, permit tuples to be assigned and returned by functions, giving rise to code such as
a, b := b, a -- swapping w/o an explicit temporary x, y := f(5); -- this f takes one argument and returns two results
Avoids the problem where an uninitialized variable has different values in different runs causing non-reproducible results. Some systems provide default initialization, for example C initializes external variables to zero.
To initialize aggregates requires a way to specify aggregate constants.
An alternative to have the system check during execution if a variable is uninitialized. For IEEE floating point this is free, since there are invalid bit patterns (NaN) that are checked by conforming hardware. To do this in general needs special hardware or expensive software checking.
Some languages, e.g., Java and C#, specify that the compiler must be able to show that no variable is used before given a value. If the compiler cannot confirm this, a warning/error message is produced.
Some languages, e.g., Java, C++, and C# permit the program to provide constructors that automatically initialize dynamically allocated variables.
Which addition is done first when you evaluate X+Y+Z? This is not trivial: one order might overflow or give a less precise result. For expressions such as f(x)+g(x) the evaluation order matters if the functions have side effects.
Sometimes the value of the first operand determines the value of
the entire binary operation.
For example 0*x is 0 for any x; True or X
is True for any X.
Not evaluating the second operand in such cases is called a
short-circuit evaluation.
Note that this is not just a performance improvement; short-circuit
evaluation changes the meaning of some programs: Consider 0*f(x),
when f has side effects (e.g. modifying x or printing).
We treat this issue in 6.4.1 (short-circuited conditions) when
discussing control statements for selection.
Homework: CYU 7. What is an l-value? An r-value? CYU 11. What are the advantages of updating a variable with an assignment operator, rather than with a regular assignment in which the variable appears on both the left- and right-hand sides?
Early languages, in particular, made heavy use of unstructured control flow, especially goto's. Much evidence was accumulated to show that the great reliance on goto's both increased the bug density and decreased the readability (these are not unrelated consequences) so today structured alternatives dominate.
A famous article (actually a letter to the editor) by Dijkstra
entitled
Go To Statement Considered Harmful
put the case before the
computing public.
Common today are
Common uses of goto have been captured by newer control statements. For example, Fortran had a DO loop (similar to C's for), but had no way other that GOTO to exit the loop early. C has a break statement for that purpose.
Homework: 24. Rubin [Rub87] used the following example (rewritten here in C) to argue in favor of a goto statement.
int first_zero_row = -1 /* none */ int i, j; for (i=0; i<n; i++) { for (j=0; j<n; j++) { if (A[i][j]) goto next; } first_zero_row = i; break; next:; }The intent of the code is to find the first all-zero row, if any, of an n×n matrix. Do you find the example convincing? Is there a good structured alternative in C? In any language?
begin begin x := y/z; x := y/z; z := F(y,z); -- function call z := F(y,z); z := G(y,z); -- array ref z := G(y,z); end; exception -- handlers when Constraint_Error => -- do something end;
The Ada code on the right illustrates exception handling.
An Ada constraint error
signifies using an incorrect value,
e.g., dividing by zero, accessing an array out of bounds, assigning
a negative value to a variable declared positive
, etc.
Ada is statically scoped but constraints have a dynamic flavor. If G raises a constraint error and does not have its own handler, then the constraint error is propagated back to the caller of G, in our case the anonymous block on the right. Note that G is not lexically inside the block so the constraint error is propagating via the call chain not scoping.
(define f (lambda (c) (c))) (define g (lambda () ;; beginning of g (call-with-current-continuation f) ;; more of g ))
A continuation encapsulates the current context, including the current location counter and bindings. Scheme contains a function
(call-with-current-continuation f)that invokes f, passing to it the current continuation c. The simplest case occurs if a function g executes the invocation above and then f immediately calls c. The result is the same as if f returned to g. This example is shown on the right.
However, the continuation is a object that can be further passed on and can be saved and reused. Hence one can get back to a given context multiple times. Moreover, if f calls h giving it c and h calls c, we are back in g without having passed through f.
One more subtlety: The binding that contained in c
is writable
.
That is, the binding is from name to memory-location-storing-the
name, not just from name to current-value.
So in the example above, if f changes the value of a name in the
binding, and then calls c, g will see the change.
Continuations are actually quite powerful and can be used to implement quite a number of constructs, but the power comes with a warning: It is easy to (mis-)use continuations to create extremely obscure programs.
for I in 1..N loop -- loop body end loop;Many languages have a means of grouping several statements together to form a
compound statement. Pascal used begin-end pairs to form compound statements, C shortened it to {}. Ada doesn't need {} because the language syntax already contains bracketing constructs. As an example, the code on the right is the Ada version of a simple C for loop. Note that the
endis part of the for statement and not a separate begin-end compound.
When declarations are combined with a sequence the result is a block. A block is written
{ declarations / statements } declare declarations begin statements endin C and Ada respectively. As usual C is more compact, Ada more readable.
Another alternative is make indentation significant. I often use this is pseudo-code; B2/ABC/Python and Haskell use it for real.
if condition then statements if (condition) statement if condition then statements_1 else statements_2 end if if condition_1 { statements_1 } else if condition_2 { statements_2} else { statements_3} if condition_1 then statements_1 elsif condition_2 then statements_2 ... elsif condition_n then statements_n end if
Essentially every language has an if statement to use for selection. The simplest case is shown in the first two lines on the right. Something is needed to separate the condition from the statement. In Pascal and Ada it is the then keyword; in C/C++/Java it is the parentheses surrounding the condition.
The next step up is if-then-else, introduced in Algol 60.
We have already seen the infamous dangling else
problem that
can occur if statements_1 is an if.
If the language uses end markers like end if, the
problem disappears; Ada uses this approach, which is shown on the
right.
Another solution, used by Algol 60, is to forbid a
then to be followed immediately by
an if (you can follow then by a
begin block starting with if).
When there are more than 2 possibilities, it is common to employ else if. The C/C++/Java syntax is shown on the right. This same idea could be used in languages featuring end if, but the result would be a bunch of end if statements at the end. To avoid requiring this somewhat ugly code, languages like Ada include an elsif which continues the existing if rather than starting a new one. Thus only one end if is required as we see in the bottom right example.
Can if (y==0 || 1/y < 100)
ever divide by zero?
Not in C which requires short-circuit evaluation.
In addition to divide by zero cases, short-circuit evaluation is helpful when searching a null-terminated list in C. In that situation the idiom is
while (ptr && ptr->val != value)The short circuit evaluation of && guarantees that we will never execute null->val.
But sometimes we do not want to short-circuit the evaluation. For example, consider f(x)&&g(x) where g(x) has a desirable side effect that we want to employ even when f(x) is false. In C this cannot be done directly.
if C1 and C2 -- always eval C2 if C1 and then C2 -- not always
Ada has both possibilities. The operator or always evaluates both arguments; whereas, the operator or else does a short circuit evaluation.
Homework: 12. Neither Algol 60 nore Algol 68 employs short-circuit evaluation for Boolean expressions. In both languages, however, an if...then...else construct can be used as an expression. Show how to use if...then...else to achieve the effect of short-circuit evaluations.
case nextChar is when 'I' => Value := 1; when 'V' => Value := 5; when 'X' => Value := 10; when 'C' => Value := 100; when 'D' => Value := 500; when 'M' => Value := 1000; when others => raise InvalidRomanNumeral; end case;
Most modern languages have a case or switch statement where the selection is based on the value of a single expression. The code on the right was written in Ada by a student of ancient Rome. The raise instruction causes an exception to be raised and thus control is transferred to the appropriate handler.
A case statement can always be simulated by a sequence of if statements, but the case statement is clearer.
In addition to increased clarity over multiple if statements, the case statement can, in many situations be compiled into better code. Most languages having this construct require that the tested for values ('I', 'V', 'X', 'C', 'D', and 'M' above) must be computable at compile time. For example in Ada, they must be manifest constants, or ranges such as 5..8 composed of manifest constants, or a disjunction of these (e.g., 6 | 9..20 | 8).
In the simplest case where each choice is a constants, a jump table can be constructed and just two jumps are executed, independent of the number of cases. The size of the jump table is the range of choices so would be impractical if the case expression was an arbitrary integer. If the when arms formed consecutive integers, the implementation would jump to others if out of range (using comparison operators) and then use a jump table for the when arms.
Hashing techniques are also used (see 3e). In the general case the code produced is a bunch of tests and tables.
The languages differ in details.
The words case
and when
in Ada become switch
and case
in C.
Ada requires that all possible values of the case expression be
accounted for (the others construct makes this
easy).
C and Fortran 90 simply do nothing when the expression does not
match any arm even though C does support a default
arm.
switch (x+3) { case 2: statements_2 break; case 1: statements_1 break; case 6: statements_6 // NO break between case 5: statements_5 // cases 6 and 5 break; default: something }
The C switch statement is rather peculiar. There are no ranges allowed and the cases flow from one to the other unless there is a break, a common cause of errors for beginners. So if x is 3, x+3 is 6 and both statements_6 and statements_5 are executed.
Indeed, the cases are really just statement labels. This can lead to quite unstructured code including the infamous ...
void send (int *to, int *from, int count) { int n = (count + 7) /8; switch (count % 8) { case 0: do { *to++ = *from++; case 7: *to++ = *from++; case 6: *to++ = *from++; case 5: *to++ = *from++; case 4; *to++ = *from++; case 3: *to++ = *from++; case 2: *to++ = *from++; case 1: *to++ = *from++; } while (--n > 0); } }
This function is actually perfectly legal C code. It was discovered by Tom Duff, a very strong graphics programmer, formerly at Bell Labs now at Pixar. The normal way to copy n integers is to loop n times. But that requires n tests and n jumps.
Duff unrolled
the loop 8 times meaning that he looped n/8
times with each body copying 8 integers.
Unrolling is a well known technique, nothing new yet.
But, if n is say 804, then after doing 100 iterations with 8 copies,
you need to do one extra iteration to copy the last 4.
The standard technique is to write two loops, one does n/8
iterations with 8 copies, the other does n%8 iterations with one
copy.
Duff's device uses only one loop.
Compiler technology has improved since Duff created the device and Wikipedia says that when it was removed from part of the X-window system, performance increased.
for I in A..N loop statements end loop; for (int i=A; i<n; i++) { statements }
On the right we see the basic for loop from Ada and C.
There is a tension between flexibility and clarity. An Ada advocate would say that for loops are just for iterating over a domain; there are other (e.g., while) loops that should be used for other cases. A C advocate finds the for(;;) construct an expressive and concise way to implement important idioms.
This tension leads to different answers for several design questions.
increment clauseneed not increment at all. In Ada the increment is implicit; the result is that the loop travels through the entire range specified after in.
for i in reverse A..N loop statements end loop;
declare type Weekday is (Mon, Tues, Wed, Thurs, Fri); I : Integer -- **NOT** used in loop below begin for I in Weekday loop statements end loop; endMany languages, including Ada, permit the range to be any discrete set. So an enumeration type is permitted as shown on the right, but a range of Floats is not.
for (expr1; expr2; expr3) statements; expr1; while (expr2); { statements; } expr3;
The C for loop is actually quite close to the while loops we discuss below since the three clause within the for can be arbitrary expressions, including assignments and other side-effects. Indeed the for loop on the right is defined by C to have the same semantics as the while loop below it unless the statements in the body include a continue statement.
Loops combining both enumeration and logical control are also found in Algol 60 and Common Lisp.
We saw above an example of a loop with range an enum Weekday=(Mon,Tues,Wed,Thurs,Fri). More generally, some languages, e.g., Clu, Python, Ruby, C++, Euclid, and C# permit the programmer to provide an iterator with any container (an object of a discrete type).
for (thing=first(); anyMore(); thing=next()); { statements };
The programmer then writes first
, next
, and
anyMore
operators that in succession yield all elements of
the container.
So the generalization of the C for loop becomes
something like the code on the right.
As an example imagine a binary tree considered as a container of nodes. One could write three different iterators: preorder, postorder, and inorder. Then, using the appropriate iterator in the loop shown above would perform a preorder/postorder/inorder traversal of the tree.
Essentially all languages have while loops of the general form
while condition loop statements end loop;In while loops the condition is tested each iteration before the statements are executed. In particular, if the condition is initially false, no iterations are executed.
In fact all loops can be expressed using this form, for suitable
conditions and statements.
This universality
proves useful when performing
invariant/assertion reasoning and proving programs consistent with
their specifications.
repeat statements until condition; do { statements } while (condition); first := true; while (first or else condition) loop statements first := false; end loop;
Sometimes we want to perform the test at the bottom of the loop (and thus execute the body at least once). The first two lines on the right show how this is done in Pascal and C respectively. The difference between the two is that the repeat-until construct terminates when the condition is true; whereas, the do-while terminates when the condition is false.
The do-while is more popular. One language that has neither is Ada so code like the bottom right is needed to simulate do-while.
Outer: while C1 loop statements_1a Mid: while C2 loop statements_2a exit outer when condition_fini; inner:while C3 loop statements_3 exit when condition_2b; exit mid when condition_1b; end loop inner; statements_2b end loop mid; statements_1b end loop outer;
More generally, we might want to break out of a loop somewhere
in the middle
, that is, at an arbitrary point in the body.
Many languages offer this ability, in C/C++ it is the
break statement.
Even more generally, we might want to break out of a nested loop. Indeed, if we are nested three deep, we might want to end the two innermost loops and continue with the outer loop at the point where the middle loop ends.
In C/C++ a goto is needed to break out of several layers; Java extends the C/C++ break with labels to support multi-level breaks; and Ada uses exit to break out and a labeled exit (as shown on the right) for multi-level breaks.
For example, if condition_fini evaluates true, the entire loop nest is exited; if condition_2b evaluates true, statements_2b is executed; and if condition_1b evaluates true, statements_1b is executed.
We will discuss recursion again when we study subprograms. Here we just want to illustrate its relationship to iteration.
Although iteration is indicated by a keyword statement such as
while
, for
, or loop
, and recursion is indicated
by a procedure/function invocation, the concepts are more closely
related than the divergent syntax suggests.
Both iteration and recursion are used to enable a section to be
executed repeatedly.
#include <stdio.h> int summation (int (*f)(int x), int low, int high) { int total = 0; int i; for (i = low; i <= high; i++) { total += (*f)(i); } return total; } int square(int x) { return x * x; } int main (int argc, char *argv[]) { printf ("Ans is %d", summation(&square,1,3)); return 0; } (define square (lambda (x) (* x x))) (define summation (lambda (f lo hi) (cond ((> lo hi) 0) ((= lo hi) (f lo)) (else (+ (summation f lo (- hi 1)) (f hi)))))) (summation square 1 3) (summation (lambda (x) (* x x)) 1 3)
On the right we see three versions of a function to compute
Sum (from i = lo) (to i = hi) f(i)given three parameters, lo, hi, and (a pointer to) f.
A recursive function is called tail recursive if the last thing it does before returning is to invoke itself recursively.
In this case we do not need to keep multiple
copies of the local variables since, when one invocation calls the
next, the first is finished with
its copy of the variables
and the second one can reuse them rather than pushing another set of
local variables on the stack.
This is very helpful for for performance.
int gcc (int a, int b) { int gcc (int a, int b) { start: if (a == b) return a; if (a == b) return a; if (a > b) return gcd(a-b,b); if (a > b) { a = a-b; goto start; } return gcd(a,b-a); } b = b-a; goto start; }
The two examples on the right are from the book and show equivalent
C programs for gcd: the left (tail-) recursive, the right iterative.
Both versions assume the parameters are positive.
Good compilers (especially, those for functional languages
will automatically convert tail recursive functions into equivalent
iterative form.
I very much recommend the book The Little Schemer by Friedman and Felleisen.