Three important concepts
In writing any computer program, we start with some specification of
a problem to be solved by that program and a set of primitive,
pre-defined operations that can be performed on our computer...that
set of primitive operations constitutes the programming language that
we'll use. Creating a program that solves the previously-specified
problem using the pre-defined operations in our programming language
involves the repeated application of three different but related
concepts. These three concepts are:
abstraction: giving something a name; treating something
complex as if it were simpler; throwing away
detail. (Or at least postponing it.)
We can reduce any complex thing to a simpler
thing in this way, and worry about the details later.
Abstraction includes the notion of decomposition,
but it's not equivalent to decomposition.
reference: mentioning something by name. This allows us
to use what was previously abstracted away.
synthesis: combining two simple things to make a more
complex thing; the opposite of abstraction.
(And, of course, those primitive, previously-defined operations
are what allow you to stop abstracting.)
These three concepts are essential to controlling the complexity of big
programs, which is one of the things we'll stress in this course. In fact,
the person who wrote one of the LISP books we recommend thinks that
abstraction is the most important concept in computer science:
"The most important concept in all of computer science is
abstraction. Computer science deals with information and
complexity. We make complexity manageable by judiciously
reducing it when and where possible.
"I regret that I cannot recall who remarked that computation
is the art of carefully throwing away information: given an
overwhelming collection of data, you reduce it to a usable
result by discarding most of its content"
Guy Steele
Using these concepts - an example
Consider the quadratic formula that most of you remembered from high school
math classes (sorry for the crude ASCII representation):
________
/ 2
-b +- \/ b - 4ac
x = _________________
2a
This formula tells us what must be computed in finding the roots or
x-intercepts of a quadratic expression, but it doesn't exactly tell us how,
at least if we look at this from the point of view of a computer. In order
to get a computer to perform these computations, we're going to have to use
this formula as a specification for the design and implementation of a program.
The design
How would we design a program to find the roots of a quadratic expression,
following the constraints imposed by what we already know about the
functional programming paradigm?
First, that +- operator tells us that there are two roots, and accordingly we
can turn this problem of finding the roots into two smaller problems,
each finding one root. Right there we've done an abstraction---we've pushed
some detail out of the way. We give the big problem a meaningful name, and
we do the same for the subproblems. We'll call these problems "quadratic",
"pos-root", and "neg-root". Using a little bit of English and a little bit
of mathematical notation as a design language, we can describe what we've
done like this:
quadratic (a,b,c)
is defined as pos-root (a,b,c)
combined with neg-root (a,b,c)
^ ^
| |
| here's an example of reference
|
|
here's an example of synthesis
The pos-root problem can be further abstracted. Essentially, it's a
division---the result of the numerator divided by the denominator:
pos-root (a,b,c)
is defined as numerator (a,b,c)
divided by denominator (a,b,c)
(We don't really need the arguments b and c in that denominator function, but
in the spirit of abstraction, we'll postpone worrying about that detail for
now. We did it differently in class.)
To break down the numerator problem, we find that it's the sum of -b and that
ugly square root, which has a mathematical name: the discriminant.
Well, actually, it's the thing under the square root symbol that's
called the discriminant, and we'll make sure that's reflected herein.
numerator (a,b,c)
is defined as negate (b)
plus sqrt-of-discriminant (a,b,c)
What about the denominator? That's easy:
denominator (a,b,c)
is defined as a
multiplied by 2
As for the sqrt-of-discriminant itself, we've left that as part of your
first homework assignment. What about neg-root? It's pretty much the
same as pos-root, except that I'm going to have to change numerator so
that it subtracts instead of adds. For consistency, I'll go back and
change the name of numerator to pos-numerator, and I'll change pos-root
accordingly:
pos-root (a,b,c)
is defined as pos-numerator (a,b,c)
divided by denominator (a,b,c)
pos-numerator (a,b,c)
is defined as negate (b)
plus sqrt-of-discriminant (a,b,c)
and then I'll reuse pos-root and pos-numerator with a little bit of adaptation
to give me neg-root and neg-numerator:
neg-root (a,b,c)
is defined as neg-numerator (a,b,c)
divided by denominator (a,b,c)
neg-numerator (a,b,c)
is defined as negate (b)
minus sqrt-of-discriminant (a,b,c)
Unless we want to decompose things like minus and plus, we have pretty much
abstracted this problem as much as we can. Since in this course we're going
to be using Common LISP, we can treat each one of these abstractions as a
design for a LISP function.
The implementation
How do we turn the design into LISP code? LISP in its early days was a
purely functional language and, even though there are lots of things you can
do in LISP now that do not adhere to functional programming constraints, it
still retains much of that functional flavor, some of which shows through in
the language's syntax. Since we've used a functional notation in our design,
converting the design to working LISP code will require nothing more than
some simple cosmetic changes. We start with this:
quadratic (a,b,c)
is defined as pos-root (a,b,c)
combined with neg-root (a,b,c)
pos-root (a,b,c)
is defined as pos-numerator (a,b,c)
divided by denominator (a,b,c)
pos-numerator (a,b,c)
is defined as negate (b)
plus sqrt-of-discriminant (a,b,c)
denominator (a,b,c)
is defined as a
multiplied by 2
neg-root (a,b,c)
is defined as neg-numerator (a,b,c)
divided by denominator (a,b,c)
neg-numerator (a,b,c)
is defined as negate (b)
minus sqrt-of-discriminant (a,b,c)
First, we'll get rid of the commas. LISP doesn't want commas; it regards
spaces as delimiters. We only used commas in our design because that comes
from standard math notation.
quadratic (a b c)
is defined as pos-root (a b c)
combined with neg-root (a b c)
pos-root (a b c)
is defined as pos-numerator (a b c)
divided by denominator (a b c)
pos-numerator (a b c)
is defined as negate (b)
plus sqrt-of-discriminant (a b c)
denominator (a b c)
is defined as a
multiplied by 2
neg-root (a b c)
is defined as neg-numerator (a b c)
divided by denominator (a b c)
neg-numerator (a b c)
is defined as negate (b)
minus sqrt-of-discriminant (a b c)
The syntax of LISP dictates that all functions begin with a left parenthesis
and end with a right parenthesis, so let's add those details:
(quadratic (a b c)
is defined as pos-root (a b c)
combined with neg-root (a b c))
(pos-root (a b c)
is defined as pos-numerator (a b c)
divided by denominator (a b c))
(pos-numerator (a b c)
is defined as negate (b)
plus sqrt-of-discriminant (a b c))
(denominator (a b c)
is defined as a
multiplied by 2)
(neg-root (a b c)
is defined as neg-numerator (a b c)
divided by denominator (a b c))
(neg-numerator (a b c)
is defined as negate (b)
minus sqrt-of-discriminant (a b c))
Note that there are two sorts of things going on in this design. Sometimes
we're "defining" functions and other times we're "invoking" them or referring
to them. The syntax for these two sorts of things are slightly different,
for reasons we'll explain in detail as the course progresses. (The dialect
of LISP called Scheme is more consistent in this regard.)
To define a new function in LISP, we use a pre-defined function called
"defun", which stands for "define function". The syntax for function
definition is this:
(defun *function-name* *list-of-arguments* *function-body*)
So now we add the function name "defun" in the appropriate places, and remove
our English equivalent, "is defined as":
(defun quadratic (a b c)
pos-root (a b c)
combined with neg-root (a b c))
(defun pos-root (a b c)
pos-numerator (a b c)
divided by denominator (a b c))
(defun pos-numerator (a b c)
negate (b)
plus sqrt-of-discriminant (a b c))
(defun denominator (a b c)
a
multiplied by 2)
(defun neg-root (a b c)
neg-numerator (a b c)
divided by denominator (a b c))
(defun neg-numerator (a b c)
negate (b)
minus sqrt-of-discriminant (a b c))
The syntax for a plain old function invocation is this:
(*function-name* *one-or-more-arguments*)
The difference in syntax between function definition and function invocation
has to do with what things get evaluated when, and we'll talk about that in
the next lecture. (Actually, function definition using "defun" is just a
special form of function invocation.) For the time being, just remember that
most of the time you'll be using the syntax shown just above. If we apply
that syntax to what we've done so far, we get this:
(defun quadratic (a b c)
(pos-root a b c)
combined with (neg-root a b c))
(defun pos-root (a b c)
(pos-numerator a b c)
divided by (denominator a b c))
(defun pos-numerator (a b c)
(negate b)
plus (sqrt-of-discriminant a b c))
(defun denominator (a b c)
a
multiplied by 2)
(defun neg-root (a b c)
(neg-numerator a b c)
divided by (denominator a b c))
(defun neg-numerator (a b c)
(negate b)
minus (sqrt-of-discriminant a b c))
Now what? We can get rid of all those English names for arithmetic functions,
and replace them with the LISP equivalents. Either of the LISP books will
tell you what those equivalents are. In case you don't have a book, I'll
tell you the equivalents:
plus is just +
minus is just -
multiplied by is just *
divided by is just /
See, I told you it was easy. But before we do those replacements, remember
the syntax of a function invocation: the name of the function comes first,
followed by the arguments to the function. It's called prefix notation.
So we don't just replace those arithmetic operators, we have to move them
into our prefix form and add the appropriate parentheses:
(defun quadratic (a b c)
(pos-root a b c)
combined with (neg-root a b c))
(defun pos-root (a b c)
(/ (pos-numerator a b c)
(denominator a b c)))
(defun pos-numerator (a b c)
(+ (negate b)
(sqrt-of-discriminant a b c)))
(defun denominator (a b c)
(* a
2))
(defun neg-root (a b c)
(/ (neg-numerator a b c)
(denominator a b c)))
(defun neg-numerator (a b c)
(- (negate b)
(sqrt-of-discriminant a b c)))
Almost done. There's no "negate" function in LISP. We just use "-" with one
argument, which LISP interprets as "subtract the argument from zero":
(defun pos-numerator (a b c)
(+ (- b)
(sqrt-of-discriminant a b c)))
(defun neg-numerator (a b c)
(- (- b)
(sqrt-of-discriminant a b c)))
The last thing we have to deal with is that nebulous "combined with"
operation. Functions return a single value, but here we want to return
two values. So what we'll do is combine the two numeric values into a
single data structure and return that. We'll use LISP's favorite data
structure, the list. The way we get LISP to combine a bunch of separate
entities into a single list is to pass those entities as arguments to a
function called "list". So, if we typed (list 2.0 -5.0) to our LISP
interpreter, LISP would return (2.0 -5.0). Packaging up our multiple
numbers into a single structure satisfies LISP's need to return a single
value as well as our need to see what looks like multiple values returned by
a single function. Here's what it all looks like after we've made this last
alteration:
(defun quadratic (a b c)
(list (pos-root a b c)
(neg-root a b c)))
(defun pos-root (a b c)
(/ (pos-numerator a b c)
(denominator a b c)))
(defun pos-numerator (a b c)
(+ (- b)
(sqrt-of-discriminant a b c)))
(defun denominator (a b c)
(* a
2))
(defun neg-root (a b c)
(/ (neg-numerator a b c)
(denominator a b c)))
(defun neg-numerator (a b c)
(- (- b)
(sqrt-of-discriminant a b c)))
That, in gory detail, is a step-by-step illustration of how one goes from a
design in some off-the-cuff high-level design language to an actual
implementation in Common LISP. Initially, we started with a formula, and we
designed a working implementation of the formula from the top-down by
breaking off a piece of the problem, designing a solution to that little
piece, and abstracting away the rest of that problem. Then we started over
on the stuff we abstracted away: we broke off a piece of the remainder,
designed the solution, abstracted away the rest, and so on. In fact, if you
go back and analyze the code, you'll see very little in the way of real LISP,
and a lot of procedure names we invented on the fly. We just treated our
problem and its subproblems as successively refined black boxes, until those
black boxes mapped directly onto primitive operations that were already
defined for us. This is called "procedural abstraction" (which differentiates
it from "data abstraction"---something we'll hear more about later on).
We were aided in this process by thinking "functionally". Thinking
"functionally" here means that your procedures access only those values
that are passed to them as arguments, that they return single values, and
that they leave no side-effects---that is, a procedure does nothing that
persists after it returns its value. That's why we're going to keep you
away from assignment operations for awhile.
If you go back and look at the LISP code just created, some things should be
obvious. We generated a lot of procedures (and we didn't even implement the
sqrt-of-discriminant!), but each one of those procedures is very small and
amazingly easy to read. In fact, I'd argue that someone who had no LISP
exposure whatsoever could figure out pretty quickly exactly what was
intended here, even without any supporting documentation. And that's another
thing---because we've taken the time to make our function names very
descriptive, we've reduced the need for in-line documentation (although we
haven't eliminated the need, so don't start thinking that you don't have to
document your work!). Also, because each of these functions are small and
adhere to our functional programming constraints, they're a snap to debug if
something goes wrong. So while one might want to argue that this approach
creates "many unnecessary procedure calls and is therefore inefficient code"
(we wouldn't want to impose on the computer, would we?) or that it requires
too many unnecessary keystrokes (your fingertips are probably bleeding just
in anticipation of all this typing), one would also be certifiably insane
if one argued that understanding and debugging this more "efficient"
(yet still functional) version:
(defun quadratic (a b c)
(list (/ (+ (- b)
(sqrt (- (* b b)
(* 4.0 a c))))
(* 2.0 a))
(/ (- (- b)
(sqrt (- (* b b)
(* 4.0 a c))))
(* 2.0 a))))
was in any way easier than understanding and debugging what we created
earlier, wouldn't one?
Clarification: "procedure" vs. "function"
Here's some stuff I don't think is worth talking about in class, but
you should know it anyway, just so you're not confused. In the discussion
above, I've been using "procedure" and "function" pretty much interchangeably.
This is not uncommon in the LISP programming community. For example,
consider this definition by Deborah G. Tatar in "A Programmer's Guide to
Common LISP":
"A function is a procedure that obeys the usual rules for
evaluation." [You'll find out what those rules are below
- Kurt]
Then she goes on to say this:
"The words 'procedure' and 'operator' are used in this book
to refer to functions, macros, or special forms. In LISP, a
procedure is a thing that you write to express a process.
There is no particular opposition between the terms
'function' and 'procedure,' since everything in LISP returns
a value, and functions usually express a process."
So this implies to me that "procedure" and "function" are interchangeable in
LISP world (although there are things called "special forms" and "macros" that
technically aren't functions according to the LISP specification; more about
those in the weeks to come). That sort of agrees with Winston and Horn in
"LISP (3rd edition)", who offer these definitions:
Procedure: A step by step specification, expressed in a
programming language, such as LISP, of how to
do something.
Function: Narrowly, a procedure that has no side effects.
Broadly, any procedure.
Like I said, these folks sort of agree, but not quite. Tatar says a function
follows the usual evaluation rules, while Winston and Horn, if we follow the
"narrow" definition, say something much more restrictive (but desirable)---
namely, that a function has no side effects. This definition is certainly in
keeping with the constraints of the functional programming paradigm, but the
evaluation rules of LISP do not prohibit side effects. In any case, don't
lose sleep over it. The big whammy here comes when we look to people outside
LISP world for definitions. For example, Fischer and Grodzinsky in
"The Anatomy of Programming Languages" say:
"In semistandard terminology, a function is a program object
that receives information through a list of arguments,
performs a prescribed computation on that information,
calculates some 'answer,' and returns that value to the
calling program....A procedure is just like a function
except that it does not return a value."
ARGH! But they go on to give themselves, and everyone else, a way out:
"We will use the word 'function' as a generic word to refer
to functions...and procedures when the distinctions among
them are not important."
So, in short, it's sort of sloppy. It's ok to call just about anything in
LISP world a function, as long as there's no need to worry about the technical
distinctions between things that are functions and things that aren't (e.g.,
macros and special forms). When you go outside of LISP world, like in CS 3411,
the people there may be a little bit more picky about how you use those words,
so be careful.
The substitution model of evaluation
Now that you know how to create and run a LISP function, it's time to become
familiar with how LISP functions are evaluated by a LISP interpreter. We
won't go into all the details here; we'll leave some of the details for the
weeks to come. But this little discussion should give you enough
understanding to make you dangerous.
Let's say you've created a file with all the LISP code for evaluating the
quadratic formula that I gave you in the previous lecture notes. Then
let's say that you've also added your implementation of the function for
computing the square root of the discriminant. Now you tell the LISP
interpreter to evaluate all those defun functions, as you were shown
in your lab session. You're ready to compute stuff. If you type
(pos-root 1 0 -4) at the LISP evaluator, here's what will happen:
? (pos-root 1 0 -4)
2
?
You can follow that a little bit more closely by telling the evaluator that
you want to "trace" some of the functions that are being called:
? (trace pos-root pos-numerator sqrt-of-discriminant denominator)
NIL
?
Then when you type (pos-root 1 0 -4), you'll see something like this:
? (pos-root 1 0 -4)
Calling (POS-ROOT 1 0 -4)
Calling (POS-NUMERATOR 1 0 -4)
Calling (SQRT-OF-DISCRIMINANT 1 0 -4)
SQRT-OF-DISCRIMINANT returned 4
POS-NUMERATOR returned 4
Calling (DENOMINATOR 1)
DENOMINATOR returned 2
POS-ROOT returned 2
2
What's really happening is something like this: When I type the list
(pos-root 1 0 -4) at the interpreter, LISP assumes I want to invoke the
function "pos-root". LISP looks up the definition of "pos-root" and
extracts that definition. It then evaluates the arguments, 1, 0, and
-4, which in this case conveniently evaluate to themselves. Then,
LISP applies that definition to the evaluated arguments. This can be
viewed as a substitution, where:
(pos-root 1 0 -4)
becomes
(/ (pos-numerator 1 0 -4) (denominator 1 0 -4))
This expression is sent to the evaluator again. LISP evaluates the arguments,
but this time the arguments aren't numbers but are instead lists. So these
expressions are handed to the evaluator, and LISP treats them as function
calls.
The order in which these function calls, or any arguments in this example,
are evaluated won't be important. Why? Because of the functional programming
constraints we've followed, there are no side-effects. For example, computing
pos-numerator in no way affects the computation of denominator and vice-versa,
so it doesn't matter which one is computed first. (This will be a very nice
feature in the parallel-processing world of the future.) If, on the other
hand, one of these functions updated some global variable that was accessed
by the other, we would most definitely worry about which one was computed
first, resulting in a harder programming job, more complex software, and so on.
Still, something like Macintosh Common LISP has to evaluate one of these
arguments before the other, and the way it works (as do most LISP systems) is
to evaluate the arguments left to right. So LISP evaluates the expression
(pos-numerator 1 0 -4) and does the appropriate substitution:
(/ (pos-numerator 1 0 -4) (denominator 1 0 -4))
becomes
(/ (+ (- 0) (sqrt-of-discriminant 1 0 -4)) (denominator 1 0 -4))
This "substitution" process continues until LISP arrives at a solution
(which requires that all the functions you defined are eventually replaced by
functions that were already defined in LISP). In short, the LISP function
evaluation process can be described like this:
1. Look up and retrieve the function definition.
2. Evaluate the arguments to the function (by passing the
arguments themselves to the LISP evaluation function---
numbers evaluate to themselves, symbols evaluate to
the objects they're bound to, while lists are treated
as function calls and evaluated accordingly).
3. Apply the function definition to the evaluated arguments
(i.e., replace the argument place-holders in the new
function definition with the corresponding evaluated
arguments).
4. Substitute the result of all that for the original
function call from step 1.
5. Pass this new expression to the evaluator.
Warning: The is a very sketchy and hand-wavy description of the evaluation
process. It should suffice for now, but as time goes by, we'll beef this
description up with details about how various types of things get evaluated.
But rather than dump all the details on you at once, we'll add things
incrementally as we need them.
Copyright 1998 by Kurt Eiselt. All rights reserved.
Last revised: January 10, 1998